diff --git a/package-lock.json b/package-lock.json index e22f853..f4719c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "2.1.0", "license": "ISC", "dependencies": { + "binary-search-bounds": "^2.0.5", "custom-card-helpers": "^1.9.0", "date-fns": "^2.29.3", "deepmerge": "^4.2.2", "lodash": "^4.17.21", "plotly.js": "^2.14.0", + "propose": "^0.0.5", "simple-statistics": "^7.8.0" }, "devDependencies": { @@ -21,8 +23,11 @@ "@types/lodash": "^4.14.191", "@types/node": "^18.11.17", "@types/plotly.js": "^2.12.11", + "@types/ws": "^8.5.4", + "chokidar": "^3.5.3", "esbuild": "^0.16.10", - "ts-jest": "^29.0.3" + "ts-jest": "^29.0.3", + "ws": "^8.12.0" } }, "node_modules/@ampproject/remapping": { @@ -1741,6 +1746,15 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" }, + "node_modules/@types/ws": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.13", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.13.tgz", @@ -1823,7 +1837,6 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", "dev": true, - "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -1989,6 +2002,15 @@ "dev": true, "peer": true }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/binary-search-bounds": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", @@ -2163,6 +2185,33 @@ "node": ">=10" } }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/ci-info": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.5.0.tgz", @@ -3237,6 +3286,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -3610,6 +3671,18 @@ "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-0.1.0.tgz", "integrity": "sha512-WRRyllsGXJM7ZN7gPTCCQ/6wNPTRDwiWdPK66l5sJzcU/oOzcIcRRf0Rux8bkpox/1yjt0F6VJRsQOIG2qz5sg==" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-blob": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz", @@ -3659,6 +3732,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-finite": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", @@ -3703,6 +3785,18 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-iexplorer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", @@ -4575,6 +4669,14 @@ "node": ">=6" } }, + "node_modules/levenshtein-edit-distance": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/levenshtein-edit-distance/-/levenshtein-edit-distance-1.0.0.tgz", + "integrity": "sha512-gpgBvPn7IFIAL32f0o6Nsh2g+5uOvkt4eK9epTfgE4YVxBxwVhJ/p1888lMm/u8mXdu1ETLSi6zeEmkBI+0F3w==", + "bin": { + "levenshtein-edit-distance": "cli.js" + } + }, "node_modules/levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -4909,7 +5011,6 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5329,6 +5430,14 @@ "node": ">= 6" } }, + "node_modules/propose": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/propose/-/propose-0.0.5.tgz", + "integrity": "sha512-Jary1vb+ap2DIwOGfyiadcK4x1Iu3pzpkDBy8tljFPmQvnc9ES3m1PMZOMiWOG50cfoAyYNtGeBzrp+Rlh4G9A==", + "dependencies": { + "levenshtein-edit-distance": "^1.0.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", @@ -5398,6 +5507,18 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/regex-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/regex-regex/-/regex-regex-1.0.0.tgz", @@ -6467,6 +6588,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -7805,6 +7947,15 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" }, + "@types/ws": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", + "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/yargs": { "version": "17.0.13", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.13.tgz", @@ -7866,7 +8017,6 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", "dev": true, - "peer": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -8002,6 +8152,12 @@ "dev": true, "peer": true }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, "binary-search-bounds": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", @@ -8129,6 +8285,22 @@ "dev": true, "peer": true }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, "ci-info": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.5.0.tgz", @@ -9048,6 +9220,15 @@ "path-is-absolute": "^1.0.0" } }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, "globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -9378,6 +9559,15 @@ "resolved": "https://registry.npmjs.org/is-base64/-/is-base64-0.1.0.tgz", "integrity": "sha512-WRRyllsGXJM7ZN7gPTCCQ/6wNPTRDwiWdPK66l5sJzcU/oOzcIcRRf0Rux8bkpox/1yjt0F6VJRsQOIG2qz5sg==" }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, "is-blob": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz", @@ -9401,6 +9591,12 @@ "has": "^1.0.3" } }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, "is-finite": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", @@ -9430,6 +9626,15 @@ "dev": true, "peer": true }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, "is-iexplorer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", @@ -10105,6 +10310,11 @@ "dev": true, "peer": true }, + "levenshtein-edit-distance": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/levenshtein-edit-distance/-/levenshtein-edit-distance-1.0.0.tgz", + "integrity": "sha512-gpgBvPn7IFIAL32f0o6Nsh2g+5uOvkt4eK9epTfgE4YVxBxwVhJ/p1888lMm/u8mXdu1ETLSi6zeEmkBI+0F3w==" + }, "levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -10402,8 +10612,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "peer": true + "dev": true }, "normalize-svg-path": { "version": "0.1.0", @@ -10740,6 +10949,14 @@ "sisteransi": "^1.0.5" } }, + "propose": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/propose/-/propose-0.0.5.tgz", + "integrity": "sha512-Jary1vb+ap2DIwOGfyiadcK4x1Iu3pzpkDBy8tljFPmQvnc9ES3m1PMZOMiWOG50cfoAyYNtGeBzrp+Rlh4G9A==", + "requires": { + "levenshtein-edit-distance": "^1.0.0" + } + }, "protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", @@ -10808,6 +11025,15 @@ } } }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, "regex-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/regex-regex/-/regex-regex-1.0.0.tgz", @@ -11686,6 +11912,13 @@ "signal-exit": "^3.0.7" } }, + "ws": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "dev": true, + "requires": {} + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 551a14b..c43bae8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "start": "esbuild src/plotly-graph-card.ts --servedir=dist --outdir=dist --bundle --sourcemap=inline", + "start": "(node ./script/hot-reload.mjs & esbuild src/plotly-graph-card.ts --servedir=dist --outdir=dist --bundle --sourcemap=inline)", "build": "esbuild src/plotly-graph-card.ts --outdir=dist --bundle --minify", "test": "jest", "test:watch": "jest --watchAll", @@ -18,15 +18,20 @@ "@types/lodash": "^4.14.191", "@types/node": "^18.11.17", "@types/plotly.js": "^2.12.11", + "@types/ws": "^8.5.4", + "chokidar": "^3.5.3", "esbuild": "^0.16.10", - "ts-jest": "^29.0.3" + "ts-jest": "^29.0.3", + "ws": "^8.12.0" }, "dependencies": { + "binary-search-bounds": "^2.0.5", "custom-card-helpers": "^1.9.0", "date-fns": "^2.29.3", "deepmerge": "^4.2.2", "lodash": "^4.17.21", "plotly.js": "^2.14.0", + "propose": "^0.0.5", "simple-statistics": "^7.8.0" } } diff --git a/readme.md b/readme.md index 5198c9f..81f260a 100644 --- a/readme.md +++ b/readme.md @@ -4,20 +4,20 @@ # Plotly Graph Card - +

- +image

- +image - +
@@ -400,8 +400,6 @@ type: custom:plotly-graph entities: - entity: sensor.temperature_in_celsius name: living temperature in Farenheit # Overrides the entity name - lambda: |- # Transforms the data - (ys) => ys.map(y => (y × 9/5) + 32) unit_of_measurement: °F # Overrides the unit show_value: true # shows the last value as text texttemplate: >- # custom format for show_value @@ -483,7 +481,7 @@ entities: # either statistics or states will be available, depending on if "statistics" are fetched or not # attributes will be available inside states only if an attribute is picked in the trace return { - ys: states.map(state => +state?.attributes?.current_temperature - state?.attributes?.target_temperature + hass.states.get("sensor.inside_temp")), + ys: states.map(state => +state?.attributes?.current_temperature - state?.attributes?.target_temperature + hass.states["sensor.temperature"].state, meta: { unit_of_measurement: "delta" } }; }, @@ -678,7 +676,7 @@ entities: internal: true period: 5minute filters: - - map_y: parseFloat(y) + - map_y: parseFloat(y) - store_var: temp1 - entity: sensor.temperature2 period: 5minute @@ -686,11 +684,114 @@ entities: filters: - map_y: parseFloat(y) - map_y: y + vars.temp1.ys[i] +``` + +### Universal functions + +Javascript functions allowed everywhere in the yaml. Evaluation is top to bottom and shallow to deep (depth first traversal). + +The returned value will be used as value for the property where it is found. E.g: + +```js +name: $fn ({ hass }) => hass.states["sensor.garden_temperature"].state +``` + +### Available parameters: -### `lambda:` transforms (deprecated) +Remember you can add a `console.log(the_object_you_want_to_inspect)` and see its content in the devTools console. -Deprecated. Use filters instead. -Your old lambdas should still work for now but this API will be removed in March 2023. +#### Everywhere: + +- `getFromConfig: (path) => value;` Pass a path (e.g `entities.0.name`) and get back its value +- `hass: HomeAssistant object;` For example: `hass.states["sensor.garden_temperature"].state` to get its current state +- `vars: Record;` You can communicate between functions with this. E.g `vars.temperatures = ys` +- `path: string;` The path of the current function +- `css_vars: HATheme;` The colors set by the active Home Assistant theme (see #ha_theme) + +#### Only iniside entities + +- `xs: Date[];` Array of timestamps +- `ys: YValue[];` Array of values of the sensor/attribute/statistic +- `statistics: StatisticValue[];` Array of statistics objects +- `states: HassEntity[];` Array of state objects +- `meta: HassEntity["attributes"];` The current attributes of the sensor + +#### Gotchas + +- 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` +- 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. +- Functions cannot return functions for performance reasons. (feature request if you need this) +- Defaults are not applied to the subelements returned by a function. (feature request if you need this) +- You can get other values from the yaml with the `getFromConfig` parameter, but if they are functions they need to be defined before. + +#### Adding the last value to the entitiy's name + +```yaml +type: custom:plotly-graph +entities: + - entity: sensor.garden_temperature + name: | + $fn ({ ys,meta }) => + meta.friendly_name + " " + ys[ys.length - 1] +``` + +#### Sharing data across functions + +```yaml +type: custom:plotly-graph +entities: + - entity: sensor.garden_temperature + + # 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 + fn: | + $fn ({ ys, vars }) => + vars.title = ys[ys.length - 1] +title: $fn ({ vars }) => vars.title +``` + +#### Histograms + +```yaml +type: custom:plotly-graph +entities: + - entity: sensor.openweathermap_temperature + x: $fn ({ys,vars}) => ys + type: histogram +title: Temperature Histogram last 10 days +hours_to_show: 240 +raw_plotly_config: true +layout: + margin: + t: 0 + l: 50 + b: 40 + height: 285 + xaxis: + autorange: true +``` + +#### custom hover text + +```yaml +type: custom:plotly-graph +title: hovertemplate +entities: + - entity: climate.living + attribute: current_temperature + customdata: | + $fn ({states}) => + states.map( ({state, attributes}) =>({ + ...attributes, + state + }) + ) + hovertemplate: |- +
Mode: %{customdata.state}
+ Target:%{y}
+ Current:%{customdata.current_temperature} + +hours_to_show: 24 +``` ## Default trace & axis styling @@ -715,24 +816,35 @@ defaults: To define layout aspects, like margins, title, axes names, ... Anything from https://plotly.com/javascript/reference/layout/. -### disable default layout: +### Home Assistant theming: + +Toggle Home Assistant theme colors: -Use this if you want to use plotly default layout instead. Very useful for heavy customization while following pure plotly examples. +- card-background-color +- primary-background-color +- primary-color +- primary-text-color +- secondary-text-color ```yaml type: custom:plotly-graph entities: - entity: sensor.temperature_in_celsius -no_default_layout: true +ha_theme: false #defaults to true ``` -### disable Home Assistant themes: +### Raw plotly config: + +Toggle all in-built defaults for layout and entitites. Useful when using histograms, 3d plots, etc. +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. ```yaml type: custom:plotly-graph entities: - entity: sensor.temperature_in_celsius -no_theme: true + x: $fn ({xs}) => xs + y: $fn ({ys}) => ys +raw_plotly_config: true # defaults to false ``` ## config: @@ -740,20 +852,6 @@ no_theme: true To define general configurations like enabling scroll to zoom, disabling the modebar, etc. Anything from https://plotly.com/javascript/configuration-options/. -## significant_changes_only - -When true, will tell HA to only fetch datapoints with a different state as the one before. -More here: https://developers.home-assistant.io/docs/api/rest/ under `/api/history/period/` - -Caveats: - -1. zana-37 repoorts that `minimal_response: false` needs to be set to get all non-significant datapoints [here](https://github.com/dbuezas/lovelace-plotly-graph-card/issues/34#issuecomment-1085083597). -2. This configuration will be ignored (will be true) while fetching [Attribute Values](#Attribute-Values). - -```yaml -significant_changes_only: true # defaults to false -``` - ## disable_pinch_to_zoom ```yaml @@ -762,19 +860,6 @@ disable_pinch_to_zoom: true # defaults to false When true, the custom implementations of pinch-to-zoom and double-tap-drag-to-zooming will be disabled. -## minimal_response - -When true, tell HA to only return last_changed and state for states other than the first and last state (much faster). -More here: https://developers.home-assistant.io/docs/api/rest/ under `/api/history/period/` - -Caveats: - -1. This configuration will be ignored (will be false) while fetching [Attribute Values](#Attribute-Values). - -```yaml -minimal_response: false # defaults to true -``` - ## hours_to_show: How many hours are shown. diff --git a/script/hot-reload.mjs b/script/hot-reload.mjs new file mode 100755 index 0000000..c9a87b8 --- /dev/null +++ b/script/hot-reload.mjs @@ -0,0 +1,36 @@ +// @ts-check +import WebSocket, { WebSocketServer } from "ws"; +import chokidar from "chokidar"; + +const watchOptn = { + // awaitWriteFinish: {stabilityThreshold:100, pollInterval:50}, + ignoreInitial: true, +}; +async function hotReload() { + const wss = new WebSocketServer({ port: 8081 }); + wss.on("connection", () => console.log(wss.clients.size)); + wss.on("close", () => console.log(wss.clients.size)); + const sendToClients = ( + /** @type {{ action: string; payload?: any }} */ message + ) => { + wss.clients.forEach(function each( + /** @type {{ readyState: number; send: (arg0: string) => void; }} */ client + ) { + if (client.readyState === WebSocket.OPEN) { + console.log("sending"); + client.send(JSON.stringify(message)); + } + }); + }; + chokidar.watch("src", watchOptn).on("all", async (...args) => { + console.log(args); + try { + sendToClients({ action: "update-app" }); + } catch (e) { + console.error(e); + sendToClients({ action: "error", payload: e.message }); + } + }); +} + +hotReload(); diff --git a/src/cache/Cache.ts b/src/cache/Cache.ts index f667b8d..ccf6951 100644 --- a/src/cache/Cache.ts +++ b/src/cache/Cache.ts @@ -13,9 +13,19 @@ import { CachedStateEntity, EntityData, } from "../types"; -import { groupBy } from "lodash"; -import { StatisticValue } from "../recorder-types"; - +type FetchConfig = + | { + statistic: "state" | "sum" | "min" | "max" | "mean"; + period: "5minute" | "hour" | "day" | "week" | "month"; + entity: string; + } + | { + attribute: string; + entity: string; + } + | { + entity: string; + }; export function mapValues( o: Record, fn: (value: T, key: string) => S @@ -24,10 +34,8 @@ export function mapValues( } async function fetchSingleRange( hass: HomeAssistant, - entity: EntityConfig, - [startT, endT]: number[], - significant_changes_only: boolean, - minimal_response: boolean + entity: FetchConfig, + [startT, endT]: number[] ): Promise<{ range: [number, number]; history: CachedEntity[]; @@ -76,13 +84,7 @@ async function fetchSingleRange( if (isEntityIdStatisticsConfig(entity)) { history = await fetchStatistics(hass, entity, [start, end]); } else { - history = await fetchStates( - hass, - entity, - [start, end], - significant_changes_only, - minimal_response - ); + history = await fetchStates(hass, entity, [start, end]); } let range: [number, number] = [startT, endT]; @@ -95,13 +97,13 @@ async function fetchSingleRange( }; } -export function getEntityKey(entity: EntityConfig) { +export function getEntityKey(entity: FetchConfig) { if (isEntityIdAttrConfig(entity)) { - return `${entity.entity}::attribute::${entity.offset}`; + return `${entity.entity}::attribute:`; } else if (isEntityIdStatisticsConfig(entity)) { - return `${entity.entity}::statistics::${entity.period}::${entity.offset}`; + return `${entity.entity}::statistics::${entity.period}`; } else if (isEntityIdStateConfig(entity)) { - return `${entity.entity}::${entity.offset}`; + return `${entity.entity}`; } throw new Error(`Entity malformed:${JSON.stringify(entity)}`); } @@ -110,9 +112,9 @@ const MIN_SAFE_TIMESTAMP = Date.parse("0001-01-02T00:00:00.000Z"); export default class Cache { ranges: Record = {}; histories: Record = {}; - busy = Promise.resolve(); // mutex + busy: Promise = Promise.resolve(null as unknown as EntityData); // mutex - add(entity: EntityConfig, states: CachedEntity[], range: [number, number]) { + add(entity: FetchConfig, states: CachedEntity[], range: [number, number]) { const entityKey = getEntityKey(entity); let h = (this.histories[entityKey] ??= []); h.push(...states); @@ -129,7 +131,8 @@ export default class Cache { this.ranges = {}; this.histories = {}; } - getData(entity: EntityConfig): EntityData { + + getData(entity: FetchConfig): EntityData { let key = getEntityKey(entity); const history = this.histories[key] || []; const data: EntityData = { @@ -138,7 +141,7 @@ export default class Cache { states: [], statistics: [], }; - data.xs = history.map(({ x }) => new Date(+x + entity.offset)); + data.xs = history.map(({ x }) => x); if (isEntityIdStatisticsConfig(entity)) { data.statistics = (history as CachedStatisticsEntity[]).map( ({ statistics }) => statistics @@ -159,62 +162,23 @@ export default class Cache { // and https://github.com/dbuezas/lovelace-plotly-graph-card/commit/3d915481002d03011bcc8409c2dcc6e6fb7c8674#r94899109 y === "unavailable" || y === "none" || y === "unknown" ? null : y ); - /** - * ToDo: offset traces should also be extended, but only up to the limits of the fetched range - * Otherwise, the datapoint can go way into the future and mess up auto-ranging. - */ - if (entity.extend_to_present && data.xs.length > 0 && entity.offset === 0) { - const last_i = data.xs.length - 1; - data.xs.push(new Date(Date.now() + entity.offset)); - data.ys.push(data.ys[last_i]); - if (data.states.length) data.states.push(data.states[last_i]); - if (data.statistics.length) data.statistics.push(data.statistics[last_i]); - } return data; } - async update( - range: TimestampRange, - entities: EntityConfig[], - hass: HomeAssistant, - significant_changes_only: boolean, - minimal_response: boolean - ) { + async fetch(range: TimestampRange, entity: FetchConfig, hass: HomeAssistant) { return (this.busy = this.busy .catch(() => {}) .then(async () => { range = range.map((n) => Math.max(MIN_SAFE_TIMESTAMP, n)); // HA API can't handle negative years - const parallelFetches = Object.values(groupBy(entities, getEntityKey)); - const promises = parallelFetches.flatMap(async (entityGroup) => { - // Each entity in entityGroup will result in exactly the same fetch - // But these may differ once the offsets PR is merged - // Making these fetches sequentially ensures that the already fetched ranges of each - // request are not fetched more than once - for (const entity of entityGroup) { - if (!entity.entity) continue; - const entityKey = getEntityKey(entity); - this.ranges[entityKey] ??= []; - const offsetRange = [ - range[0] - entity.offset, - range[1] - entity.offset, - ]; - const rangesToFetch = subtractRanges( - [offsetRange], - this.ranges[entityKey] - ); - for (const aRange of rangesToFetch) { - const fetchedHistory = await fetchSingleRange( - hass, - entity, - aRange, - significant_changes_only, - minimal_response - ); - this.add(entity, fetchedHistory.history, fetchedHistory.range); - } + if (entity.entity) { + const entityKey = getEntityKey(entity); + this.ranges[entityKey] ??= []; + const rangesToFetch = subtractRanges([range], this.ranges[entityKey]); + for (const aRange of rangesToFetch) { + const fetchedHistory = await fetchSingleRange(hass, entity, aRange); + this.add(entity, fetchedHistory.history, fetchedHistory.range); } - }); - - await Promise.all(promises); + } + return this.getData(entity); })); } } diff --git a/src/cache/fetch-states.ts b/src/cache/fetch-states.ts index 9bb178c..f858f18 100644 --- a/src/cache/fetch-states.ts +++ b/src/cache/fetch-states.ts @@ -10,28 +10,19 @@ import { async function fetchStates( hass: HomeAssistant, entity: EntityIdStateConfig | EntityIdAttrConfig, - [start, end]: [Date, Date], - significant_changes_only?: boolean, - minimal_response?: boolean + [start, end]: [Date, Date] ): Promise { - const no_attributes_query = isEntityIdAttrConfig(entity) - ? "" - : "no_attributes&"; - const minimal_response_query = - isEntityIdAttrConfig(entity) || minimal_response == false - ? "" - : "minimal_response&"; - const significant_changes_only_query = - isEntityIdAttrConfig(entity) || significant_changes_only == false - ? "0" - : "1"; const uri = `history/period/${start.toISOString()}?` + - `filter_entity_id=${entity.entity}&` + - `significant_changes_only=${significant_changes_only_query}&` + - `${no_attributes_query}&` + - minimal_response_query + - `end_time=${end.toISOString()}`; + [ + `filter_entity_id=${entity.entity}`, + `significant_changes_only=0`, + isEntityIdAttrConfig(entity) ? "" : "no_attributes&", + isEntityIdAttrConfig(entity) ? "" : "minimal_response&", + `end_time=${end.toISOString()}`, + ] + .filter(Boolean) + .join("&"); let list: HassEntity[] | undefined; try { const lists: HassEntity[][] = (await hass.callApi("GET", uri)) || []; diff --git a/src/duration/duration.ts b/src/duration/duration.ts index 54b395f..fb8a7d3 100644 --- a/src/duration/duration.ts +++ b/src/duration/duration.ts @@ -17,6 +17,7 @@ export type TimeDurationStr = `${number}${TimeUnit}` | `0`; * @returns duration in milliseconds */ export const parseTimeDuration = (str: TimeDurationStr | undefined): number => { + if (str === "0") return 0; if (!str || !str.match) throw new Error(`Cannot parse "${str}" as a duration`); const match = str.match( diff --git a/src/filters/filters.ts b/src/filters/filters.ts index 448650e..077cdc7 100644 --- a/src/filters/filters.ts +++ b/src/filters/filters.ts @@ -100,6 +100,7 @@ const filters = { x: +xs[0], y: NaN, }; + checkTimeUnits(unit); return { meta: { ...meta, @@ -119,6 +120,7 @@ const filters = { integrate: (unit: keyof typeof timeUnits = "h") => ({ xs, ys, meta }) => { + checkTimeUnits(unit); let yAcc = 0; let last = { x: NaN, @@ -284,3 +286,10 @@ const filters = { }, } satisfies Record FilterFn>; export default filters; +function checkTimeUnits(unit: string) { + if (!timeUnits[unit]) { + throw new Error( + `Unit '${unit}' is not valid, use ${Object.keys(timeUnits)}` + ); + } +} diff --git a/src/hot-reload.ts b/src/hot-reload.ts new file mode 100644 index 0000000..fd98baa --- /dev/null +++ b/src/hot-reload.ts @@ -0,0 +1,15 @@ +import isProduction from "./is-production"; + +if (!isProduction) { + const socket = new WebSocket("ws://localhost:8081"); + socket.addEventListener("connection", (event) => { + console.log("connected ", event); + }); + socket.addEventListener("message", async (event) => { + if ((window as any).no_hot_reload) return; + console.log("Message from server ", event); + const { action, payload } = JSON.parse(event.data); + if (action === "update-app") window.location.reload(); + if (action === "error") console.warn(payload); + }); +} diff --git a/src/parse-config.ts b/src/parse-config.ts deleted file mode 100644 index 9ea9720..0000000 --- a/src/parse-config.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { getIsPureObject } from "./utils"; -import { - AutoPeriodConfig, - StatisticPeriod, - StatisticType, - STATISTIC_PERIODS, - STATISTIC_TYPES, -} from "./recorder-types"; -import colorSchemes, { - ColorSchemeArray, - isColorSchemeArray, -} from "./color-schemes"; -import { Config, EntityConfig, InputConfig } from "./types"; -import { parseTimeDuration } from "./duration/duration"; -import merge from "lodash/merge"; -import filters from "./filters/filters"; - -function parseColorScheme(config: InputConfig): ColorSchemeArray { - const schemeName = config.color_scheme ?? "category10"; - const colorScheme = isColorSchemeArray(schemeName) - ? schemeName - : colorSchemes[schemeName] || - colorSchemes[Object.keys(colorSchemes)[schemeName]] || - null; - if (colorScheme === null) { - throw new Error( - `color_scheme: "${ - config.color_scheme - }" is not valid. Valid are an array of colors (see readme) or ${Object.keys( - colorSchemes - )}` - ); - } - return colorScheme; -} - -function getIsAutoPeriodConfig(periodObj: any): periodObj is AutoPeriodConfig { - if (!getIsPureObject(periodObj)) return false; - let lastDuration = -1; - for (const durationStr in periodObj) { - const period = periodObj[durationStr]; - const duration = parseTimeDuration(durationStr as any); // will throw if not a valud duration - if (!STATISTIC_PERIODS.includes(period as any)) { - throw new Error( - `Error parsing automatic period config: "${period}" not expected. Must be ${STATISTIC_PERIODS}` - ); - } - if (duration <= lastDuration) { - throw new Error( - `Error parsing automatic period config: ranges must be sorted in ascending order, "${durationStr}" not expected` - ); - } - lastDuration = duration; - } - return true; -} -function parseStatistics(entity: InputConfig["entities"][0]) { - if (!entity.statistic && !entity.period) return {}; - const statistic: StatisticType = entity.statistic || "mean"; - - if (!STATISTIC_TYPES.includes(statistic)) - throw new Error( - `statistic: "${entity.statistic}" is not valid. Use ${STATISTIC_TYPES}` - ); - if (getIsAutoPeriodConfig(entity.period)) { - return { - statistic, - autoPeriod: entity.period, - period: undefined, - }; - } - if (entity.period === "auto") { - return { - statistic, - autoPeriod: { - "0s": "5minute", - "1d": "hour", - "7d": "day", - "28d": "week", - "12M": "month", - }, - period: undefined, - }; - } - const period: StatisticPeriod = entity.period || "hour"; - if (!STATISTIC_PERIODS.includes(period)) - throw new Error( - `period: "${entity.period}" is not valid. Use ${STATISTIC_PERIODS}` - ); - return { - statistic, - period, - autoPeriod: undefined, - }; -} -function parseEntities(config: InputConfig): EntityConfig[] { - const colorScheme = parseColorScheme(config); - return config.entities.map((entityIn, entityIdx) => { - if (typeof entityIn === "string") entityIn = { entity: entityIn }; - entityIn.entity ??= ""; - const [oldAPI_entity, oldAPI_attribute] = entityIn.entity.split("::"); - if (oldAPI_attribute) { - entityIn.entity = oldAPI_entity; - entityIn.attribute = oldAPI_attribute; - } - entityIn = merge( - { - hovertemplate: `%{customdata.name}
%{x}
%{y}%{customdata.unit_of_measurement}`, - mode: "lines", - show_value: false, - line: { - width: 1, - shape: "hv", - color: colorScheme[entityIdx % colorScheme.length], - }, - }, - config.defaults?.entity, - entityIn - ); - - const statisticConfig = parseStatistics(entityIn); - if (entityIn.filters && !Array.isArray(entityIn.filters)) { - throw new Error( - "filters: should be an array, did you forget adding '-' before the filter name?" - ); - } - const parsedFilters = (entityIn.filters || []).map((obj) => { - let filterName: string; - let config: any = null; - if (typeof obj === "string") { - filterName = obj; - } else { - filterName = Object.keys(obj)[0]; - config = Object.values(obj)[0]; - } - const filter = filters[filterName]; - if (!filter) { - throw new Error( - `Filter '${filterName} must be [${Object.keys(filters)}]` - ); - } - return config === null ? filter() : filter(config); - }); - return { - ...(entityIn as any), // ToDo: make this type safe - internal: !!entityIn.internal, - offset: parseTimeDuration(entityIn.offset ?? "0s"), - lambda: entityIn.lambda && window.eval(entityIn.lambda), - filters: parsedFilters, - ...statisticConfig, - extend_to_present: - entityIn.extend_to_present ?? !statisticConfig.statistic, - }; - }); -} - -export default function parseConfig(config: InputConfig): Config { - if ( - typeof config.refresh_interval !== "number" && - config.refresh_interval !== undefined && - config.refresh_interval !== "auto" - ) { - throw new Error( - `refresh_interval: "${config.refresh_interval}" is not valid. Must be either "auto" or a number (in seconds). ` - ); - } - return { - title: config.title, - hours_to_show: config.hours_to_show ?? 1, - refresh_interval: config.refresh_interval ?? "auto", - offset: parseTimeDuration(config.offset ?? "0s"), - entities: parseEntities(config), - layout: merge( - { - yaxis: merge({}, config.defaults?.yaxes), - yaxis2: merge({}, config.defaults?.yaxes), - yaxis3: merge({}, config.defaults?.yaxes), - yaxis4: merge({}, config.defaults?.yaxes), - yaxis5: merge({}, config.defaults?.yaxes), - yaxis6: merge({}, config.defaults?.yaxes), - yaxis7: merge({}, config.defaults?.yaxes), - yaxis8: merge({}, config.defaults?.yaxes), - yaxis9: merge({}, config.defaults?.yaxes), - yaxis10: merge({}, config.defaults?.yaxes), - yaxis11: merge({}, config.defaults?.yaxes), - yaxis12: merge({}, config.defaults?.yaxes), - yaxis13: merge({}, config.defaults?.yaxes), - yaxis14: merge({}, config.defaults?.yaxes), - yaxis15: merge({}, config.defaults?.yaxes), - yaxis16: merge({}, config.defaults?.yaxes), - yaxis17: merge({}, config.defaults?.yaxes), - yaxis18: merge({}, config.defaults?.yaxes), - yaxis19: merge({}, config.defaults?.yaxes), - yaxis20: merge({}, config.defaults?.yaxes), - yaxis21: merge({}, config.defaults?.yaxes), - yaxis22: merge({}, config.defaults?.yaxes), - yaxis23: merge({}, config.defaults?.yaxes), - yaxis24: merge({}, config.defaults?.yaxes), - yaxis25: merge({}, config.defaults?.yaxes), - yaxis26: merge({}, config.defaults?.yaxes), - yaxis27: merge({}, config.defaults?.yaxes), - yaxis28: merge({}, config.defaults?.yaxes), - yaxis29: merge({}, config.defaults?.yaxes), - yaxis30: merge({}, config.defaults?.yaxes), - }, - config.layout - ), - config: { - ...config.config, - }, - no_theme: config.no_theme ?? false, - no_default_layout: config.no_default_layout ?? false, - significant_changes_only: config.significant_changes_only ?? false, - minimal_response: config.minimal_response ?? true, - disable_pinch_to_zoom: config.disable_pinch_to_zoom ?? false, - }; -} diff --git a/src/parse-config/defaults.ts b/src/parse-config/defaults.ts new file mode 100644 index 0000000..607fe79 --- /dev/null +++ b/src/parse-config/defaults.ts @@ -0,0 +1,211 @@ +import { merge } from "lodash"; +import { Config, InputConfig } from "../types"; +import { parseColorScheme } from "./parse-color-scheme"; +import { getEntityIndex } from "./parse-config"; +import getThemedLayout, { HATheme } from "./themed-layout"; + +const defaultEntityRequired = { + entity: "", + show_value: false, + internal: false, + time_offset: "0s", +}; +const defaultEntityOptional = { + mode: "lines", + line: { + width: 1, + shape: "hv", + color: ({ getFromConfig, path }) => { + const color_scheme = parseColorScheme(getFromConfig("color_scheme")); + return color_scheme[getEntityIndex(path) % color_scheme.length]; + }, + }, + // extend_to_present: true unless using statistics. Defined inside parse-config.ts to avoid forward depndency + unit_of_measurement: ({ meta }) => meta.unit_of_measurement || "", + name: ({ meta, getFromConfig }) => { + let name = meta.friendly_name || getFromConfig(`.entity`); + const attribute = getFromConfig(`.attribute`); + if (attribute) name += ` (${attribute}) `; + return name; + }, + hovertemplate: ({ getFromConfig }) => + `${getFromConfig(".name")}
%{x}
%{y} ${getFromConfig( + ".unit_of_measurement" + )}`, + yaxis: ({ getFromConfig, path }) => { + const units: string[] = []; + for (let i = 0; i <= getEntityIndex(path); i++) { + const unit = getFromConfig(`entities.${i}.unit_of_measurement`); + const internal = getFromConfig(`entities.${i}.internal`); + if (!internal && !units.includes(unit)) units.push(unit); + } + const yaxis_idx = units.length; + return "y" + (yaxis_idx === 1 ? "" : yaxis_idx); + }, +}; + +const defaultYamlRequired = { + title: "", + hours_to_show: 1, + refresh_interval: "auto", + color_scheme: "category10", + time_offset: "0s", + raw_plotly_config: false, + ha_theme: true, + disable_pinch_to_zoom: false, + raw_plotly: false, + defaults: { + entity: {}, + yaxes: {}, + }, + layout: {}, +}; + +// + +const defaultExtraYAxes: Partial = { + // automargin: true, // it makes zooming very jumpy + side: "right", + overlaying: "y", + showgrid: false, + visible: false, + // This makes sure that the traces are rendered above the right y axis, + // including the marker and its text. Useful for show_value. See cliponaxis in entity + layer: "below traces", +}; + +const defaultYamlOptional: { + layout: Partial; + config: Partial; +} = { + config: { + displaylogo: false, + scrollZoom: true, + modeBarButtonsToRemove: ["resetScale2d", "toImage", "lasso2d", "select2d"], + }, + layout: { + height: 285, + dragmode: "pan", + xaxis: { + autorange: false, + type: "date", + // automargin: true, // it makes zooming very jumpy + }, + yaxis: { + // automargin: true, // it makes zooming very jumpy + }, + yaxis2: { + // automargin: true, // it makes zooming very jumpy + ...defaultExtraYAxes, + visible: true, + }, + ...Object.fromEntries( + Array.from({ length: 27 }).map((_, i) => [ + `yaxis${i + 3}`, + { ...defaultExtraYAxes }, + ]) + ), + legend: { + orientation: "h", + bgcolor: "transparent", + x: 0, + y: 1, + yanchor: "bottom", + }, + title: { + y: 1, + pad: { + t: 15, + }, + }, + modebar: { + // vertical so it doesn't occlude the legend + orientation: "v", + }, + margin: { + b: 50, + t: 0, + l: 60, + // @ts-expect-error functions are not a plotly thing, only this card + r: ({ getFromConfig }) => { + const entities = getFromConfig(`entities`); + const usesRightAxis = entities.some(({ yaxis }) => yaxis === "y2"); + const usesShowValue = entities.some(({ show_value }) => show_value); + return usesRightAxis | usesShowValue ? 60 : 30; + }, + }, + }, +}; + +export function addPreParsingDefaults( + yaml_in: InputConfig, + css_vars: HATheme +): InputConfig { + let yaml = merge({}, yaml_in, defaultYamlRequired, yaml_in); + // merging in two steps to ensure ha_theme and raw_plotly_config took its default value + yaml = merge( + {}, + yaml, + { + layout: yaml.ha_theme ? getThemedLayout(css_vars) : {}, + }, + yaml.raw_plotly_config ? {} : defaultYamlOptional, + yaml_in + ); + for (let i = 1; i < 31; i++) { + const yaxis = "yaxis" + (i == 1 ? "" : i); + yaml.layout[yaxis] = merge( + {}, + yaml.layout[yaxis], + yaml.defaults.yaxes, + yaml.layout[yaxis] + ); + } + yaml.entities = yaml.entities.map((entity) => { + if (typeof entity === "string") entity = { entity }; + entity.entity ??= ""; + const [oldAPI_entity, oldAPI_attribute] = entity.entity.split("::"); + if (oldAPI_attribute) { + entity.entity = oldAPI_entity; + entity.attribute = oldAPI_attribute; + } + entity = merge( + {}, + entity, + defaultEntityRequired, + yaml.raw_plotly_config ? {} : defaultEntityOptional, + yaml.defaults?.entity, + entity + ); + return entity; + }); + return yaml; +} + +export function addPostParsingDefaults( + yaml: Config & { visible_range: [number, number] } +): Config { + /** + * These cannot be done via defaults because they depend on the entities already being fully evaluated and filtered + * */ + const yAxisTitles = Object.fromEntries( + yaml.entities.map(({ unit_of_measurement, yaxis }) => [ + "yaxis" + yaxis?.slice(1), + { title: unit_of_measurement }, + ]) + ); + const layout = merge( + {}, + yaml.layout, + yaml.raw_plotly_config + ? {} + : { + xaxis: { + range: yaml.visible_range, + }, + }, + yaml.raw_plotly_config ? {} : yAxisTitles, + yaml.layout + ); + return merge({}, yaml, { layout }, yaml); +} diff --git a/src/parse-config/deprecations.ts b/src/parse-config/deprecations.ts new file mode 100644 index 0000000..ecc6509 --- /dev/null +++ b/src/parse-config/deprecations.ts @@ -0,0 +1,29 @@ +import { parseTimeDuration } from "../duration/duration"; + +export default function getDeprecationError(path: string, value: any) { + const e = _getDeprecationError(path, value); + if (e) return new Error(`at [${path}]: ${e}`); + return null; +} +function _getDeprecationError(path: string, value: any) { + if (path.match(/^no_theme$/)) + return "renamed to ha_theme (inverted logic) in v3.0.0"; + if (path.match(/^no_default_layout$/)) + return "replaced with more general raw_plotly_config in v3.0.0"; + if (path.match(/^offset$/)) return "renamed to time_offset in v3.0.0"; + if (path.match(/^entities\.\d+\.offset$/)) { + try { + parseTimeDuration(value); + return 'renamed to time_offset in v3.0.0 to avoid conflicts with bar-offsets'; + } catch (e) { + // bar-offsets are numbers without time unit + } + } + if (path.match(/^entities\.\d+\.lambda$/)) + return "removed in v3.0.0, use filters instead"; + if (path.match(/^significant_changes_only$/)) + return "removed in v3.0.0, it is now always set to false"; + if (path.match(/^minimal_response$/)) + return "removed in v3.0.0, if you need attributes use the 'attribute' parameter instead."; + return null; +} diff --git a/src/color-schemes.ts b/src/parse-config/parse-color-scheme.ts similarity index 81% rename from src/color-schemes.ts rename to src/parse-config/parse-color-scheme.ts index 1fe101c..563854b 100644 --- a/src/color-schemes.ts +++ b/src/parse-config/parse-color-scheme.ts @@ -1,10 +1,13 @@ +import { InputConfig } from "../types"; + /* Usage example in YAML: color_scheme: accent color_scheme: 0 # both mean the same */ -export type ColorSchemeArray = string[] +export type ColorSchemeArray = string[]; +// prettier-ignore const colorSchemes = { // https://vega.github.io/vega/docs/schemes/#categorical accent: ["#7fc97f","#beaed4","#fdc086","#ffff99","#386cb0","#f0027f","#bf5b17","#666666"], @@ -34,9 +37,27 @@ const colorSchemes = { pink_foam: ["#54bebe", "#76c8c8", "#98d1d1", "#badbdb", "#dedad2", "#e4bcad", "#df979e", "#d7658b", "#c80064"], salmon_to_aqua: ["#e27c7c", "#a86464", "#6d4b4b", "#503f3f", "#333333", "#3c4e4b", "#466964", "#599e94", "#6cd4c5"], } -export function isColorSchemeArray(obj: any): obj is ColorSchemeArray{ - return Array.isArray(obj) +function isColorSchemeArray(obj: any): obj is ColorSchemeArray { + return Array.isArray(obj); } -export default colorSchemes -export type ColorSchemeNames = keyof typeof colorSchemes +export type ColorSchemeNames = keyof typeof colorSchemes; + +export function parseColorScheme( + color_scheme: InputConfig["color_scheme"] +): ColorSchemeArray { + const schemeName = color_scheme ?? "category10"; + const colorScheme = isColorSchemeArray(schemeName) + ? schemeName + : colorSchemes[schemeName] || + colorSchemes[Object.keys(colorSchemes)[schemeName]] || + null; + if (colorScheme === null) { + throw new Error( + `color_scheme: "${color_scheme}" is not valid. Valid are an array of colors (see readme) or ${Object.keys( + colorSchemes + )}` + ); + } + return colorScheme; +} diff --git a/src/parse-config/parse-config.ts b/src/parse-config/parse-config.ts new file mode 100644 index 0000000..21b6bd8 --- /dev/null +++ b/src/parse-config/parse-config.ts @@ -0,0 +1,380 @@ +import Cache from "../cache/Cache"; +import { HATheme } from "./themed-layout"; + +import propose from "propose"; + +import get from "lodash/get"; +import { addPreParsingDefaults, addPostParsingDefaults } from "./defaults"; +import { parseTimeDuration } from "../duration/duration"; +import { parseStatistics } from "./parse-statistics"; +import { HomeAssistant } from "custom-card-helpers"; +import filters from "../filters/filters"; +import bounds from "binary-search-bounds"; +import { has } from "lodash"; +import { StatisticValue } from "../recorder-types"; +import { Config, EntityData, HassEntity, InputConfig, YValue } from "../types"; +import getDeprecationError from "./deprecations"; + +class ConfigParser { + private yaml: Partial = {}; + private errors?: Error[]; + private yaml_with_defaults?: InputConfig; + private hass?: HomeAssistant; + cache = new Cache(); + private busy = false; + private fnParam!: FnParam; + private observed_range: [number, number] = [Date.now(), Date.now()]; + public resetObservedRange() { + this.observed_range = [Date.now(), Date.now()]; + } + + async update(input: { + yaml: InputConfig; + hass: HomeAssistant; + css_vars: HATheme; + }) { + if (this.busy) throw new Error("ParseConfig was updated while busy"); + this.busy = true; + try { + return this._update(input); + } finally { + this.busy = false; + } + } + private async _update({ + yaml: input_yaml, + hass, + css_vars, + }: { + yaml: InputConfig; + hass: HomeAssistant; + css_vars: HATheme; + }): Promise<{ errors: Error[]; parsed: Config }> { + this.yaml = {}; + this.errors = []; + this.hass = hass; + this.yaml_with_defaults = addPreParsingDefaults(input_yaml, css_vars); + + this.fnParam = { + vars: {}, + path: "", + hass, + css_vars, + getFromConfig: () => "", + }; + for (const [key, value] of Object.entries(this.yaml_with_defaults)) { + try { + await this.evalNode({ + parent: this.yaml, + path: key, + key: key, + value, + }); + } catch (e) { + console.warn(`Plotly Graph Card: Error parsing [${key}]`, e); + this.errors?.push(e as Error); + } + } + this.yaml = addPostParsingDefaults(this.yaml as Config); + + return { errors: this.errors, parsed: this.yaml as Config }; + } + private async evalNode({ + parent, + path, + key, + value, + }: { + parent: object; + path: string; + key: string; + value: any; + }) { + if (path.match(/^defaults$/)) return; + this.fnParam.path = path; + this.fnParam.getFromConfig = (pathQuery: string) => + this.getEvaledPath(pathQuery, path /* caller */); + + if ( + !this.fnParam.xs && // hasn't fetched yet + path.match(/^entities\.\d+\./) && + !path.match( + /^entities\.\d+\.(entity|attribute|time_offset|statistic|period)/ + ) && //isInsideFetchParamNode + (is$fn(value) || path.match(/^entities\.\d+\.filters\.\d+$/)) // if function of filter + ) { + const entityPath = path.match(/^(entities\.\d+)\./)![1]; + await this.fetchDataForEntity(entityPath); + } + + if (typeof value === "string" && value.startsWith("$fn")) { + value = myEval(value.slice(3)); + } + const error = getDeprecationError(path, value); + if (error) this.errors?.push(error); + + if (typeof value === "function") { + /** + * Allowing functions that return functions makes it very slow when large arrays are returned. + * This is because awaits are expensive. + */ + + parent[key] = value = value(this.fnParam); + } else if (isObjectOrArray(value)) { + const me = Array.isArray(value) ? [] : {}; + parent[key] = me; + for (const [childKey, childValue] of Object.entries(value)) { + const childPath = `${path}.${childKey}`; + try { + await this.evalNode({ + parent: me, + path: childPath, + key: childKey, + value: childValue, + }); + } catch (e: any) { + console.warn(`Plotly Graph Card: Error parsing [${childPath}]`, e); + this.errors?.push(new Error(`at [${childPath}]: ${e?.message || e}`)); + } + } + } else { + parent[key] = value; + } + + // we're now on the way back of traversal, `value` is fully evaluated (not a function) + + if (path.match(/^entities\.\d+\.filters\.\d+$/)) { + this.evalFilter({ parent, path, key, value }); + } + if (path.match(/^entities\.\d+$/)) { + if (!this.fnParam.xs) { + await this.fetchDataForEntity(path); + } + const me = parent[key]; + if (!this.fnParam.getFromConfig("raw_plotly_config")) { + if (!me.x) me.x = this.fnParam.xs; + if (!me.y) me.y = this.fnParam.ys; + if (me.x.length === 0 && me.y.length === 0) { + /* + Traces with no data are removed from the legend by plotly. + Setting them to have null element prevents that. + */ + me.x = [new Date()]; + me.y = [null]; + } + } + + delete this.fnParam.xs; + delete this.fnParam.ys; + delete this.fnParam.statistics; + delete this.fnParam.states; + delete this.fnParam.meta; + } + if (path.match(/^entities$/)) { + parent[key] = parent[key].filter(({ internal }) => !internal); + const entities = parent[key]; + const count = entities.length; + // Preserving the original sequence of real_traces is important for `fill: tonexty` + // https://github.com/dbuezas/lovelace-plotly-graph-card/issues/87 + for (let i = 0; i < count; i++) { + const trace = entities[i]; + if (trace.show_value) { + trace.legendgroup ??= "group" + i; + entities.push({ + texttemplate: `%{y:.2~f} ${this.fnParam.getFromConfig( + `entities.${i}.unit_of_measurement` + )}`, // here so it can be overwritten + ...trace, + 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 + mode: "text+markers", + showlegend: false, + hoverinfo: "skip", + textposition: "middle right", + marker: { + color: trace.line?.color, + }, + textfont: { + color: trace.line?.color, + }, + x: trace.x.slice(-1), + y: trace.y.slice(-1), + }); + } + } + } + } + + private async fetchDataForEntity(path: string) { + let visible_range = this.fnParam.getFromConfig("visible_range"); + if (!visible_range) { + const hours_to_show = this.fnParam.getFromConfig("hours_to_show"); + const global_offset = parseTimeDuration( + this.fnParam.getFromConfig("time_offset") + ); + const ms = hours_to_show * 60 * 60 * 1000; + visible_range = [ + +new Date() - ms + global_offset, + +new Date() + global_offset, + ] as [number, number]; + this.yaml.visible_range = visible_range; + } + this.observed_range[0] = Math.min(this.observed_range[0], visible_range[0]); + this.observed_range[1] = Math.max(this.observed_range[1], visible_range[1]); + const statisticsParams = parseStatistics( + visible_range, + this.fnParam.getFromConfig(path + ".statistic"), + this.fnParam.getFromConfig(path + ".period") + ); + const attribute = this.fnParam.getFromConfig(path + ".attribute") as + | string + | undefined; + const fetchConfig = { + entity: this.fnParam.getFromConfig(path + ".entity"), + ...(statisticsParams ? statisticsParams : attribute ? { attribute } : {}), + }; + const offset = parseTimeDuration( + this.fnParam.getFromConfig(path + ".time_offset") + ); + + const range_to_fetch = [ + visible_range[0] - offset, + visible_range[1] - offset, + ]; + const fetch_mask = this.fnParam.getFromConfig("fetch_mask"); + const i = getEntityIndex(path); + const data = + // TODO: decide about minimal response + fetch_mask[i] === false // also fetch if it is undefined. This means the entity is new + ? this.cache.getData(fetchConfig) + : await this.cache.fetch(range_to_fetch, fetchConfig, this.hass!); + const extend_to_present = + this.fnParam.getFromConfig(path + ".extend_to_present") ?? + !statisticsParams; + + data.xs = data.xs.map((x) => new Date(+x + offset)); + + removeOutOfRange(data, this.observed_range); + if (extend_to_present && data.xs.length > 0) { + // Todo: should this be done after the entity was fully evaluated? + // this would make it also work if filters change the data. + // Would also need to be combined with yet another removeOutOfRange call. + const last_i = data.xs.length - 1; + const now = Math.min(this.observed_range[1], Date.now()); + data.xs.push(new Date(Math.min(this.observed_range[1], now + offset))); + data.ys.push(data.ys[last_i]); + if (data.states.length) data.states.push(data.states[last_i]); + if (data.statistics.length) data.statistics.push(data.statistics[last_i]); + } + this.fnParam.xs = data.xs; + this.fnParam.ys = data.ys; + this.fnParam.statistics = data.statistics; + this.fnParam.states = data.states; + this.fnParam.meta = this.hass?.states[fetchConfig.entity]?.attributes || {}; + } + + private getEvaledPath(path: string, callingPath: string) { + if (path.startsWith(".")) + path = callingPath + .split(".") + .slice(0, -1) + .concat(path.slice(1).split(".")) + .join("."); + if (has(this.yaml, path)) return get(this.yaml, path); + + let value = this.yaml_with_defaults; + for (const key of path.split(".")) { + if (value === undefined) return undefined; + value = value[key]; + if (is$fn(value)) { + throw new Error( + `Since [${path}] is a $fn, it has to be defined before [${callingPath}]` + ); + } + } + return value; + } + private evalFilter(input: { + parent: object; + path: string; + key: string; + value: any; + }) { + const obj = input.value; + let filterName: string; + let config: any = null; + if (typeof obj === "string") { + filterName = obj; + } else { + filterName = Object.keys(obj)[0]; + config = Object.values(obj)[0]; + } + const filter = filters[filterName]; + if (!filter) { + throw new Error( + `Filter '${filterName}' doesn't exist. Did you mean ${propose( + filterName, + Object.keys(filters) + )}?` + ); + } + const filterfn = config === null ? filter() : filter(config); + try { + const r = filterfn(this.fnParam); + for (const key in r) { + this.fnParam[key] = r[key]; + } + } catch (e) { + console.error(e); + throw new Error(`Error in filter: ${e}`); + } + } +} + +const myEval = typeof window != "undefined" ? window.eval : global.eval; + +function isObjectOrArray(value) { + return value !== null && typeof value == "object" && !(value instanceof Date); +} + +function is$fn(value) { + return ( + typeof value === "function" || + (typeof value === "string" && value.startsWith("$fn")) + ); +} + +function removeOutOfRange(data: EntityData, range: [number, number]) { + const first = bounds.le(data.xs, new Date(range[0])); + if (first > -1) { + data.xs.splice(0, first); + data.xs[0] = new Date(range[0]); + data.ys.splice(0, first); + data.states.splice(0, first); + data.statistics.splice(0, first); + } + const last = bounds.gt(data.xs, new Date(range[1])); + if (last > -1) { + data.xs.splice(last); + data.ys.splice(last); + data.states.splice(last); + data.statistics.splice(last); + } +} + +type FnParam = { + getFromConfig: ( + string + ) => ReturnType["getEvaledPath"]>; + hass: HomeAssistant; + vars: Record; + path: string; + css_vars: HATheme; + xs?: Date[]; + ys?: YValue[]; + statistics?: StatisticValue[]; + states?: HassEntity[]; + meta?: HassEntity["attributes"]; +}; +export const getEntityIndex = (path: string) => + +path.match(/entities\.(\d+)/)![1]; +export { ConfigParser }; diff --git a/src/parse-config/parse-statistics.ts b/src/parse-config/parse-statistics.ts new file mode 100644 index 0000000..3ddf5f1 --- /dev/null +++ b/src/parse-config/parse-statistics.ts @@ -0,0 +1,78 @@ +import { getIsPureObject } from "../utils"; +import { + AutoPeriodConfig, + StatisticPeriod, + StatisticType, + STATISTIC_PERIODS, + STATISTIC_TYPES, +} from "../recorder-types"; + +import { parseTimeDuration } from "../duration/duration"; + +function getIsAutoPeriodConfig(periodObj: any): periodObj is AutoPeriodConfig { + if (!getIsPureObject(periodObj)) return false; + let lastDuration = -1; + for (const durationStr in periodObj) { + const period = periodObj[durationStr]; + const duration = parseTimeDuration(durationStr as any); // will throw if not a valud duration + if (!STATISTIC_PERIODS.includes(period as any)) { + throw new Error( + `Error parsing automatic period config: "${period}" not expected. Must be ${STATISTIC_PERIODS}` + ); + } + if (duration <= lastDuration) { + throw new Error( + `Error parsing automatic period config: ranges must be sorted in ascending order, "${durationStr}" not expected` + ); + } + lastDuration = duration; + } + return true; +} +export function parseStatistics( + visible_range: number[], + statistic?: StatisticType, + period?: StatisticPeriod | "auto" | AutoPeriodConfig +) { + if (!statistic && !period) return null; + statistic ??= "mean"; + period ??= "hour"; + if (period === "auto") { + period = { + "0": "5minute", + "1d": "hour", + "7d": "day", + "28d": "week", + "12M": "month", + }; + } + if (getIsAutoPeriodConfig(period)) { + const autoPeriod = period; + period = "5minute"; + const timeSpan = visible_range[1] - visible_range[0]; + const mapping = Object.entries(autoPeriod).map( + ([duration, period]) => + [parseTimeDuration(duration as any), period] as [ + number, + StatisticPeriod + ] + ); + + for (const [fromMS, aPeriod] of mapping) { + /* + the durations are validated to be sorted in ascendinig order + when the config is parsed + */ + if (timeSpan >= fromMS) period = aPeriod; + } + } + if (!STATISTIC_TYPES.includes(statistic)) + throw new Error( + `statistic: "${statistic}" is not valid. Use ${STATISTIC_TYPES}` + ); + if (!STATISTIC_PERIODS.includes(period)) + throw new Error( + `period: "${period}" is not valid. Use ${STATISTIC_PERIODS}` + ); + return { statistic, period }; +} diff --git a/src/parse-config/themed-layout.ts b/src/parse-config/themed-layout.ts new file mode 100644 index 0000000..37afeaa --- /dev/null +++ b/src/parse-config/themed-layout.ts @@ -0,0 +1,35 @@ +export type HATheme = { + "card-background-color": string; + "primary-background-color": string; + "primary-color": string; + "primary-text-color": string; + "secondary-text-color": string; +}; + +const themeAxisStyle = { + tickcolor: "rgba(127,127,127,.3)", + gridcolor: "rgba(127,127,127,.3)", + linecolor: "rgba(127,127,127,.3)", + zerolinecolor: "rgba(127,127,127,.3)", +}; + +export default function getThemedLayout( + haTheme: HATheme +): Partial { + return { + paper_bgcolor: haTheme["card-background-color"], + plot_bgcolor: haTheme["card-background-color"], + font: { + color: haTheme["secondary-text-color"], + size: 11, + }, + xaxis: { ...themeAxisStyle }, + yaxis: { ...themeAxisStyle }, + ...Object.fromEntries( + Array.from({ length: 28 }).map((_, i) => [ + `yaxis${i + 2}`, + { ...themeAxisStyle }, + ]) + ), + }; +} diff --git a/src/plotly-graph-card.ts b/src/plotly-graph-card.ts index 87c0fc5..e11c7f7 100644 --- a/src/plotly-graph-card.ts +++ b/src/plotly-graph-card.ts @@ -1,55 +1,26 @@ import { HomeAssistant } from "custom-card-helpers"; -import merge from "lodash/merge"; -import mapValues from "lodash/mapValues"; import EventEmitter from "events"; +import mapValues from "lodash/mapValues"; import { version } from "../package.json"; import insertStyleHack from "./style-hack"; import Plotly from "./plotly"; import { Config, - EntityData, InputConfig, isEntityIdAttrConfig, isEntityIdStateConfig, isEntityIdStatisticsConfig, - TimestampRange, } from "./types"; -import Cache from "./cache/Cache"; -import getThemedLayout from "./themed-layout"; import isProduction from "./is-production"; +import "./hot-reload"; import { debounce, sleep } from "./utils"; import { parseISO } from "date-fns"; -import { StatisticPeriod } from "./recorder-types"; -import { parseTimeDuration } from "./duration/duration"; -import parseConfig from "./parse-config"; import { TouchController } from "./touch-controller"; +import { ConfigParser } from "./parse-config/parse-config"; +import { merge } from "lodash"; const componentName = isProduction ? "plotly-graph" : "plotly-graph-dev"; -function removeOutOfRange(data: EntityData, range: TimestampRange) { - let first = -1; - - for (let i = 0; i < data.xs.length; i++) { - if (+data.xs[i]! < range[0]) first = i; - } - if (first > -1) { - data.xs.splice(0, first); - data.ys.splice(0, first); - data.states.splice(0, first); - data.statistics.splice(0, first); - } - let last = -1; - for (let i = data.xs.length - 1; i >= 0; i--) { - if (+data.xs[i]! > range[1]) last = i; - } - if (last > -1) { - data.xs.splice(last); - data.ys.splice(last); - data.states.splice(last); - data.statistics.splice(last); - } -} - console.info( `%c ${componentName.toUpperCase()} %c ${version} ${process.env.NODE_ENV}`, "color: orange; font-weight: bold; background: black", @@ -61,18 +32,19 @@ export class PlotlyGraph extends HTMLElement { data: (Plotly.PlotData & { entity: string })[]; layout: Plotly.Layout; }; - msgEl: HTMLElement; + errorMsgEl: HTMLElement; cardEl: HTMLElement; resetButtonEl: HTMLButtonElement; titleEl: HTMLElement; config!: InputConfig; parsed_config!: Config; - cache = new Cache(); size: { width?: number; height?: number } = {}; _hass?: HomeAssistant; isBrowsing = false; isInternalRelayout = 0; touchController: TouchController; + configParser = new ConfigParser(); + pausedRendering = false; handles: { resizeObserver?: ResizeObserver; relayoutListener?: EventEmitter; @@ -122,21 +94,26 @@ export class PlotlyGraph extends HTMLElement { border: 0px; border-radius: 3px; } - #msg { + #error-msg { position: absolute; - color: red; + color: #ffffff; top: 0; - background: rgba(0, 0, 0, 0.4); + padding: 10px; + width: calc(100% - 20px); + background: rgba(203,0,0,0.8); overflow-wrap: break-word; - width: 100%; + display: none; + } + #error-msg a{ + color: mediumturquoise; }
- + `; - this.msgEl = shadow.querySelector("#msg")!; + this.errorMsgEl = shadow.querySelector("#error-msg")!; this.cardEl = shadow.querySelector("ha-card")!; this.contentEl = shadow.querySelector("div#plotly")!; this.resetButtonEl = shadow.querySelector("button#reset")!; @@ -145,23 +122,12 @@ export class PlotlyGraph extends HTMLElement { this.contentEl.style.visibility = "hidden"; this.touchController = new TouchController({ el: this.contentEl, - onZoomStart: async () => { - await this.withoutRelayout(async () => { - if (this.contentEl.layout.xaxis.autorange) { - // when autoranging is set in the xaxis, pinch to zoom doesn't work well - await Plotly.relayout(this.contentEl, { "xaxis.autorange": false }); - // for some reason, only relayout or plot aren't enough - await this.plot(); - } - }); - }, - onZoom: async (layout) => { - await this.withoutRelayout(async () => { - await Plotly.relayout(this.contentEl, layout); - }); + onZoomStart: () => { + this.pausedRendering = true; }, onZoomEnd: () => { - this.onRelayout(); + this.pausedRendering = false; + this.plot({ should_fetch: true }); }, }); this.withoutRelayout(() => Plotly.newPlot(this.contentEl, [], {})); @@ -181,13 +147,7 @@ export class PlotlyGraph extends HTMLElement { // else ==> Mansonry ==> let the height be determined by defaults this.size.height = height - this.titleEl.offsetHeight; } - this.withoutRelayout(async () => { - const layout = this.getLayout([]); - await Plotly.relayout(this.contentEl, { - width: layout.width, - height: layout.height, - }); - }); + this.plot({ should_fetch: false }); }; this.handles.resizeObserver = new ResizeObserver(updateCardSize); this.handles.resizeObserver.observe(this.cardEl); @@ -203,7 +163,7 @@ export class PlotlyGraph extends HTMLElement { )!; this.resetButtonEl.addEventListener("click", this.exitBrowsingMode); this.touchController.connect(); - this.fetch(); + this.plot({ should_fetch: true }); } disconnectedCallback() { @@ -225,43 +185,35 @@ export class PlotlyGraph extends HTMLElement { } if (this.parsed_config?.refresh_interval === "auto") { let shouldPlot = false; - let shouldFetch = false; + let should_fetch = false; for (const entity of this.parsed_config.entities) { const state = hass.states[entity.entity]; const oldState = this._hass?.states[entity.entity]; if (state && oldState !== state) { + shouldPlot = true; const start = new Date(oldState?.last_updated || state.last_updated); const end = new Date(state.last_updated); const range: [number, number] = [+start, +end]; let shouldAddToCache = false; - if (entity.offset !== 0) { - // in entities with offset, the added datapoint may be far into the future. - // Therefore, adding it messes with autoranging. - // TODO: unify entity caches independent of offsets and keep track of what has actually been - // in the viewport - shouldFetch = true; - } else if (isEntityIdAttrConfig(entity)) { + if (isEntityIdAttrConfig(entity)) { shouldAddToCache = true; } else if (isEntityIdStateConfig(entity)) { shouldAddToCache = true; } else if (isEntityIdStatisticsConfig(entity)) { - shouldFetch = true; + should_fetch = true; } if (shouldAddToCache) { - this.cache.add( + this.configParser.cache.add( entity, [{ state, x: new Date(end), y: null }], range ); - shouldPlot = true; } } } - if (shouldFetch) { - this.fetch(); - } else if (shouldPlot) { - this.plot(); + if (shouldPlot) { + this.plot({ should_fetch }, 500); } } this._hass = hass; @@ -273,28 +225,9 @@ export class PlotlyGraph extends HTMLElement { this.isInternalRelayout--; } - getAutoFetchRange() { - const ms = this.parsed_config.hours_to_show * 60 * 60 * 1000; - return [ - +new Date() - ms + this.parsed_config.offset, - +new Date() + this.parsed_config.offset, - ] as [number, number]; - } - getAutoFetchRangeWithValueMargins() { - const [start, end] = this.getAutoFetchRange(); - const padPercent = Math.max( - ...this.parsed_config.entities.map(({ show_value }) => { - if (show_value === false) return 0 / 100; - if (show_value === true) return 0 / 100; - return show_value.right_margin / 100; - }) - ); - const msToShow = this.parsed_config.hours_to_show * 1000 * 60 * 60; - const msPad = (msToShow / (1 - padPercent)) * padPercent; - return [start, end + msPad]; - } getVisibleRange() { - return this.contentEl.layout.xaxis!.range!.map((date) => { + // TODO: if the x axis is not there, or is not time, don't fetch & replot + return this.contentEl.layout.xaxis?.range?.map((date) => { // if autoscale is used after scrolling, plotly returns the dates as timestamps (numbers) instead of iso strings if (Number.isFinite(date)) return date; if (date.startsWith("-")) { @@ -324,307 +257,111 @@ export class PlotlyGraph extends HTMLElement { this.isBrowsing = false; this.resetButtonEl.classList.add("hidden"); this.withoutRelayout(async () => { - await this.plot(); // to reset xaxis to hours_to_show quickly, before refetching - this.cache.clearCache(); // so that when the user zooms out and autoranges, not more that what's visible will be autoranged - await this.fetch(); + this.configParser.resetObservedRange(); + await this.plot({ should_fetch: true }); }); }; onRestyle = async () => { // trace visibility changed, fetch missing traces if (this.isInternalRelayout) return; this.enterBrowsingMode(); - await this.fetch(); + await this.plot({ should_fetch: true }); }; onRelayout = async () => { // user panned/zoomed if (this.isInternalRelayout) return; this.enterBrowsingMode(); - await this.fetch(); + await this.plot({ should_fetch: true }); }; // The user supplied configuration. Throw an exception and Lovelace will // render an error card. async setConfig(config: InputConfig) { - try { - this.msgEl.innerText = ""; - return await this._setConfig(config); - } catch (e: any) { - console.error(e); - clearTimeout(this.handles.refreshTimeout!); - if (typeof e.message === "string") { - this.msgEl.innerText = e.message; - } else { - this.msgEl.innerText = JSON.stringify(e.message || "").replace( - /\\"/g, - '"' - ); - } - } - } - async _setConfig(config: InputConfig) { - config = JSON.parse(JSON.stringify(config)); + const was = this.config; this.config = config; - const newConfig = parseConfig(config); - const was = this.parsed_config; - this.parsed_config = newConfig; - const is = this.parsed_config; + const is = this.config; this.touchController.isEnabled = !is.disable_pinch_to_zoom; if (is.hours_to_show !== was?.hours_to_show || is.offset !== was?.offset) { this.exitBrowsingMode(); } else { - await this.fetch(); + await this.plot({ should_fetch: false }); } } - fetch = debounce(async () => { - if (!(this.parsed_config && this.hass && this.isConnected)) { - await sleep(10); - return this.fetch(); - } - - const range = this.isBrowsing - ? this.getVisibleRange() - : this.getAutoFetchRange(); - for (const entity of this.parsed_config.entities) { - if ((entity as any).autoPeriod) { - if (isEntityIdStatisticsConfig(entity) && entity.autoPeriod) { - entity.period = "5minute"; - const timeSpan = range[1] - range[0]; - const mapping = Object.entries(entity.autoPeriod).map( - ([duration, period]) => - [parseTimeDuration(duration as any), period] as [ - number, - StatisticPeriod - ] - ); - - for (const [fromMS, aPeriod] of mapping) { - /* - the durations are validated to be sorted in ascendinig order - when the config is parsed - */ - if (timeSpan >= fromMS) entity.period = aPeriod; - } - this.parsed_config.layout = merge(this.parsed_config.layout, { - xaxis: { title: `Period: ${entity.period}` }, - }); - } - } - } - const visibleEntities = this.parsed_config.entities.filter( - (_, i) => this.contentEl.data[i]?.visible !== "legendonly" - ); - try { - await this.cache.update( - range, - visibleEntities, - this.hass, - this.parsed_config.minimal_response, - this.parsed_config.significant_changes_only - ); - this.msgEl.innerText = ""; - } catch (e: any) { - console.error(e); - this.msgEl.innerText = JSON.stringify(e.message || ""); - } - await this.plot(); - }); - getThemedLayout() { + getCSSVars() { const styles = window.getComputedStyle(this.contentEl); let haTheme = { - "--card-background-color": "red", - "--primary-background-color": "red", - "--primary-color": "red", - "--primary-text-color": "red", - "--secondary-text-color": "red", + "card-background-color": "red", + "primary-background-color": "red", + "primary-color": "red", + "primary-text-color": "red", + "secondary-text-color": "red", }; - haTheme = mapValues(haTheme, (_, key) => styles.getPropertyValue(key)); - return getThemedLayout( - haTheme, - this.parsed_config.no_theme, - this.parsed_config.no_default_layout - ); + return mapValues(haTheme, (_, key) => styles.getPropertyValue("--" + key)); } - - getDataAndUnits(): { data: Plotly.Data[]; units: string[] } { - const entities = this.parsed_config.entities; - const units = [] as string[]; - let vars = {}; - const show_value_traces: Plotly.Data[] = []; - const real_traces: Plotly.Data[] = []; - entities.forEach((trace, traceIdx) => { - const entity_id = trace.entity; - const meta = { - ...this.hass?.states[entity_id]?.attributes, - }; - let data = { - ...this.cache.getData(trace), - meta, - vars, - hass: this.hass!, - }; - if (!this.isBrowsing) { - // to ensure the y axis autoranges to the visible data - removeOutOfRange(data, this.getAutoFetchRangeWithValueMargins()); - } - if (trace.filters) { - try { - for (const filter of trace.filters) { - data = { ...data, ...filter(data) }; - vars = data.vars; - } - } catch (e) { - console.error(e); - throw new Error(`Error in filter: ${e}`); - } - } - if (trace.lambda) { - try { - const history = data.ys.map((_, i) => ({ - value: data.ys[i], - timestamp: +data.xs[i], - ...data.states[i], - ...data.statistics[i], - })); - const r = trace.lambda(data.ys, data.xs, history); - if (Array.isArray(r)) { - data.ys = r; - } else { - if (r.x) data.xs = r.x; - if (r.y) data.ys = r.y; - } - } catch (e) { - console.error(e); - } - } - if (trace.internal) return; - - if (data.xs.length === 0 && data.ys.length === 0) { - /* - Traces with no data are removed from the legend by plotly. - Setting them to have null element prevents that. - */ - data.xs = [new Date()]; - data.ys = [null]; - } - - const unit_of_measurement = - trace.unit_of_measurement || data.meta.unit_of_measurement || ""; - if (!units.includes(unit_of_measurement)) units.push(unit_of_measurement); - const yaxis_idx = units.indexOf(unit_of_measurement); - - let name = data.meta.friendly_name || entity_id; - if (isEntityIdAttrConfig(trace)) name += ` (${trace.attribute}) `; - if (isEntityIdStatisticsConfig(trace)) name += ` (${trace.statistic}) `; - if (trace.name) name = trace.name; - const customdata = data.xs.map((x, i) => ({ - unit_of_measurement, - meta: data.meta, - name, - state: data.states[i], - statistic: data.statistics[i], - vars: data.vars, - i, - x, - y: data.ys[i], - })); - const mergedTrace = merge( - { - name, - customdata, - x: data.xs, - y: data.ys, - yaxis: "y" + (yaxis_idx == 0 ? "" : yaxis_idx + 1), - }, - // @ts-expect-error - data.undocumented_trace, // temporary solution until functions are allowed everyhwere. May be removed after that. - trace - ); - real_traces.push(mergedTrace); - if (mergedTrace.show_value) { - mergedTrace.legendgroup ??= "group" + traceIdx; - show_value_traces.push({ - texttemplate: `%{y:.2~f}%{customdata.unit_of_measurement}`, // here so it can be overwritten - ...mergedTrace, - 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 - mode: "text+markers", - showlegend: false, - hoverinfo: "skip", - textposition: "middle right", - marker: { - color: mergedTrace.line!.color, - }, - textfont: { - color: mergedTrace.line!.color, - }, - x: mergedTrace.x.slice(-1), - y: mergedTrace.y.slice(-1), - }); - } - }); - // Preserving the original sequence of real_traces is important for `fill: tonexty` - // https://github.com/dbuezas/lovelace-plotly-graph-card/issues/87 - return { data: [...real_traces, ...show_value_traces], units }; - } - - getLayout(units: string[]): Plotly.Layout { - const yAxisTitles = Object.fromEntries( - units.map((unit, i) => ["yaxis" + (i == 0 ? "" : i + 1), { title: unit }]) + fetchScheduled = false; + plot = async ( + { should_fetch }: { should_fetch: boolean }, + delay?: number + ) => { + if (should_fetch) this.fetchScheduled = true; + await this._plot(delay); + }; + _plot = debounce(async () => { + if (this.pausedRendering) return; + const should_fetch = this.fetchScheduled; + this.fetchScheduled = false; + let i = 0; + while (!(this.config && this.hass && this.isConnected)) { + if (i++ > 10) throw new Error("Card didn't load"); + console.log("waiting for loading"); + await sleep(100); + } + const fetch_mask = this.contentEl.data.map( + ({ visible }) => should_fetch && visible !== "legendonly" ); - const layout = merge( + const uirevision = this.isBrowsing + ? this.contentEl.layout?.uirevision || 0 + : Math.random(); + const yaml = merge( + {}, + this.config, { - uirevision: this.isBrowsing - ? this.contentEl.layout.uirevision - : Math.random(), // to trigger the autoranges in all y-yaxes - }, - { - xaxis: { - autorange: false, - range: this.isBrowsing - ? this.getVisibleRange() - : this.getAutoFetchRangeWithValueMargins(), + layout: { + ...this.size, + ...{ uirevision }, }, + fetch_mask, }, - this.parsed_config.no_default_layout ? {} : yAxisTitles, - this.getThemedLayout(), - this.size, - this.parsed_config.layout + this.isBrowsing ? { visible_range: this.getVisibleRange() } : {}, + + this.config ); - return layout; - } - getPlotlyConfig(): Partial { - return { - displaylogo: false, - scrollZoom: true, - modeBarButtonsToRemove: [ - "resetScale2d", - "toImage", - "lasso2d", - "select2d", - ], - ...this.parsed_config.config, - }; - } - plot = debounce(async () => { - if (!(this.parsed_config && this.hass && this.isConnected)) { - await sleep(10); - return this.plot(); - } - this.titleEl.innerText = this.parsed_config.title || ""; - const refresh_interval = this.parsed_config.refresh_interval; + const { errors, parsed } = await this.configParser.update({ + yaml, + hass: this.hass, + css_vars: this.getCSSVars(), + }); + this.errorMsgEl.style.display = errors.length ? "block" : "none"; + this.errorMsgEl.innerHTML = errors + .map((e) => "" + (e || "See devtools console") + "") + .join("\n
\n"); + this.parsed_config = parsed; + console.log("fetched", this.parsed_config); + + const { entities, layout, config, refresh_interval } = this.parsed_config; clearTimeout(this.handles.refreshTimeout!); if (refresh_interval !== "auto" && refresh_interval > 0) { this.handles.refreshTimeout = window.setTimeout( - () => this.fetch(), + () => this.plot({ should_fetch: true }), refresh_interval * 1000 ); } - const { data, units } = this.getDataAndUnits(); - const layout = this.getLayout(units); + this.titleEl.innerText = this.parsed_config.title || ""; if (layout.paper_bgcolor) { this.titleEl.style.background = layout.paper_bgcolor as string; } await this.withoutRelayout(async () => { - await Plotly.react(this.contentEl, data, layout, this.getPlotlyConfig()); + await Plotly.react(this.contentEl, entities, layout, config); this.contentEl.style.visibility = ""; }); }); diff --git a/src/plotly.ts b/src/plotly.ts index d9fdb33..902537e 100644 --- a/src/plotly.ts +++ b/src/plotly.ts @@ -1,54 +1,55 @@ // import Plotly from "plotly.js-dist"; // export default Plotly as typeof import("plotly.js"); +// TODO: optimize bundle size window.global = window; var Plotly = require("plotly.js/lib/core") as typeof import("plotly.js"); Plotly.register([ // traces require("plotly.js/lib/bar"), - // require("plotly.js/lib/box"), - // require("plotly.js/lib/heatmap"), - // require("plotly.js/lib/histogram"), - // require("plotly.js/lib/histogram2d"), - // require("plotly.js/lib/histogram2dcontour"), - // require("plotly.js/lib/contour"), + require("plotly.js/lib/box"), + require("plotly.js/lib/heatmap"), + require("plotly.js/lib/histogram"), + require("plotly.js/lib/histogram2d"), + require("plotly.js/lib/histogram2dcontour"), + require("plotly.js/lib/contour"), - // require("plotly.js/lib/scatterternary"), - // require("plotly.js/lib/violin"), - // require("plotly.js/lib/funnel"), - // require("plotly.js/lib/waterfall"), + require("plotly.js/lib/scatterternary"), + require("plotly.js/lib/violin"), + require("plotly.js/lib/funnel"), + require("plotly.js/lib/waterfall"), // require("plotly.js/lib/image"), // NOGO - // require("plotly.js/lib/pie"), - // require("plotly.js/lib/sunburst"), - // require("plotly.js/lib/treemap"), - // require("plotly.js/lib/icicle"), - // require("plotly.js/lib/funnelarea"), + require("plotly.js/lib/pie"), + require("plotly.js/lib/sunburst"), + require("plotly.js/lib/treemap"), + require("plotly.js/lib/icicle"), + require("plotly.js/lib/funnelarea"), - // require("plotly.js/lib/scatter3d"), - // require("plotly.js/lib/surface"), - // require("plotly.js/lib/isosurface"), - // require("plotly.js/lib/volume"), - // require("plotly.js/lib/mesh3d"), - // require("plotly.js/lib/cone"), - // require("plotly.js/lib/streamtube"), - // require("plotly.js/lib/scattergeo"), - // require("plotly.js/lib/choropleth"), - // require("plotly.js/lib/pointcloud"), - // require("plotly.js/lib/heatmapgl"), - // require("plotly.js/lib/parcats"), + require("plotly.js/lib/scatter3d"), + require("plotly.js/lib/surface"), + require("plotly.js/lib/isosurface"), + require("plotly.js/lib/volume"), + require("plotly.js/lib/mesh3d"), + require("plotly.js/lib/cone"), + require("plotly.js/lib/streamtube"), + require("plotly.js/lib/scattergeo"), + require("plotly.js/lib/choropleth"), + require("plotly.js/lib/pointcloud"), + require("plotly.js/lib/heatmapgl"), + require("plotly.js/lib/parcats"), // require("plotly.js/lib/scattermapbox"), // require("plotly.js/lib/choroplethmapbox"), - // require("plotly.js/lib/densitymapbox"), - // require("plotly.js/lib/sankey"), - // require("plotly.js/lib/indicator"), - // require("plotly.js/lib/table"), - // require("plotly.js/lib/carpet"), - // require("plotly.js/lib/scattercarpet"), - // require("plotly.js/lib/contourcarpet"), - // require("plotly.js/lib/ohlc"), - // require("plotly.js/lib/candlestick"), - // require("plotly.js/lib/scatterpolar"), - // require("plotly.js/lib/barpolar"), + // // require("plotly.js/lib/densitymapbox"), + require("plotly.js/lib/sankey"), + require("plotly.js/lib/indicator"), + require("plotly.js/lib/table"), + require("plotly.js/lib/carpet"), + require("plotly.js/lib/scattercarpet"), + require("plotly.js/lib/contourcarpet"), + require("plotly.js/lib/ohlc"), + require("plotly.js/lib/candlestick"), + require("plotly.js/lib/scatterpolar"), + require("plotly.js/lib/barpolar"), // transforms require("plotly.js/lib/aggregate"), @@ -57,7 +58,7 @@ Plotly.register([ require("plotly.js/lib/sort"), // components - // require("plotly.js/lib/calendars"), + require("plotly.js/lib/calendars"), ]); export default Plotly; diff --git a/src/themed-layout.ts b/src/themed-layout.ts deleted file mode 100644 index 7e4abdb..0000000 --- a/src/themed-layout.ts +++ /dev/null @@ -1,145 +0,0 @@ -import merge from "lodash/merge"; -import { HATheme } from "./types"; - -const defaultExtraYAxes: Partial = { - // automargin: true, // it makes zooming very jumpy - side: "right", - overlaying: "y", - showgrid: false, - visible: false, - // This makes sure that the traces are rendered above the right y axis, - // including the marker and its text. Useful for show_value. See cliponaxis in entity - layer: "below traces", -}; - -const defaultLayout: Partial = { - height: 285, - dragmode: "pan", - xaxis: { - autorange: false, - type: "date", - // automargin: true, // it makes zooming very jumpy - }, - yaxis: { - // automargin: true, // it makes zooming very jumpy - }, - yaxis2: { - // automargin: true, // it makes zooming very jumpy - ...defaultExtraYAxes, - visible: true, - }, - yaxis3: { ...defaultExtraYAxes }, - yaxis4: { ...defaultExtraYAxes }, - yaxis5: { ...defaultExtraYAxes }, - yaxis6: { ...defaultExtraYAxes }, - yaxis7: { ...defaultExtraYAxes }, - yaxis8: { ...defaultExtraYAxes }, - yaxis9: { ...defaultExtraYAxes }, - // @ts-expect-error (the types are missing yaxes > 9) - yaxis10: { ...defaultExtraYAxes }, - yaxis11: { ...defaultExtraYAxes }, - yaxis12: { ...defaultExtraYAxes }, - yaxis13: { ...defaultExtraYAxes }, - yaxis14: { ...defaultExtraYAxes }, - yaxis15: { ...defaultExtraYAxes }, - yaxis16: { ...defaultExtraYAxes }, - yaxis17: { ...defaultExtraYAxes }, - yaxis18: { ...defaultExtraYAxes }, - yaxis19: { ...defaultExtraYAxes }, - yaxis20: { ...defaultExtraYAxes }, - yaxis21: { ...defaultExtraYAxes }, - yaxis22: { ...defaultExtraYAxes }, - yaxis23: { ...defaultExtraYAxes }, - yaxis24: { ...defaultExtraYAxes }, - yaxis25: { ...defaultExtraYAxes }, - yaxis26: { ...defaultExtraYAxes }, - yaxis27: { ...defaultExtraYAxes }, - yaxis28: { ...defaultExtraYAxes }, - yaxis29: { ...defaultExtraYAxes }, - yaxis30: { ...defaultExtraYAxes }, - margin: { - b: 50, - t: 0, - l: 60, - r: 60, - }, - legend: { - orientation: "h", - bgcolor: "transparent", - x: 0, - y: 1, - yanchor: "bottom", - }, - title: { - y: 1, - pad: { - t: 15, - }, - }, - ...{ - // modebar is missing from the Layout Typings - // vertical so it doesn't occlude the legend - modebar: { - orientation: "v", - }, - }, -}; - -const themeAxisStyle = { - tickcolor: "rgba(127,127,127,.3)", - gridcolor: "rgba(127,127,127,.3)", - linecolor: "rgba(127,127,127,.3)", - zerolinecolor: "rgba(127,127,127,.3)", -}; - -export default function getThemedLayout( - haTheme: HATheme, - no_theme?: boolean, - no_default_layout?: boolean -): Partial { - const theme = { - paper_bgcolor: haTheme["--card-background-color"], - plot_bgcolor: haTheme["--card-background-color"], - font: { - color: haTheme["--secondary-text-color"], - size: 11, - }, - xaxis: { ...themeAxisStyle }, - yaxis: { ...themeAxisStyle }, - yaxis2: { ...themeAxisStyle }, - yaxis3: { ...themeAxisStyle }, - yaxis4: { ...themeAxisStyle }, - yaxis5: { ...themeAxisStyle }, - yaxis6: { ...themeAxisStyle }, - yaxis7: { ...themeAxisStyle }, - yaxis8: { ...themeAxisStyle }, - yaxis9: { ...themeAxisStyle }, - yaxis10: { ...themeAxisStyle }, - yaxis11: { ...themeAxisStyle }, - yaxis12: { ...themeAxisStyle }, - yaxis13: { ...themeAxisStyle }, - yaxis14: { ...themeAxisStyle }, - yaxis15: { ...themeAxisStyle }, - yaxis16: { ...themeAxisStyle }, - yaxis17: { ...themeAxisStyle }, - yaxis18: { ...themeAxisStyle }, - yaxis19: { ...themeAxisStyle }, - yaxis20: { ...themeAxisStyle }, - yaxis21: { ...themeAxisStyle }, - yaxis22: { ...themeAxisStyle }, - yaxis23: { ...themeAxisStyle }, - yaxis24: { ...themeAxisStyle }, - yaxis25: { ...themeAxisStyle }, - yaxis26: { ...themeAxisStyle }, - yaxis27: { ...themeAxisStyle }, - yaxis28: { ...themeAxisStyle }, - yaxis29: { ...themeAxisStyle }, - yaxis30: { ...themeAxisStyle }, - }; - - return merge( - { legend: { traceorder: "normal" } }, - no_theme ? {} : theme, - no_default_layout ? {} : defaultLayout - ); -} diff --git a/src/touch-controller.ts b/src/touch-controller.ts index 1494f71..473bd68 100644 --- a/src/touch-controller.ts +++ b/src/touch-controller.ts @@ -15,21 +15,20 @@ const ONE_FINGER_DOUBLE_TAP_ZOOM_MS_THRESHOLD = 250; export class TouchController { isEnabled = true; lastTouches?: TouchList; + clientX = 0; + clientY = 0; lastSingleTouchTimestamp = 0; elRect?: DOMRect; el: PlotlyEl; onZoomStart: () => any; - onZoom: (layout: Partial) => any; onZoomEnd: () => any; state: "one finger" | "two fingers" | "idle" = "idle"; constructor(param: { el: PlotlyEl; onZoomStart: () => any; - onZoom: (layout: Partial) => any; onZoomEnd: () => any; }) { this.el = param.el; - this.onZoom = param.onZoom; this.onZoomStart = param.onZoomStart; this.onZoomEnd = param.onZoomEnd; } @@ -63,6 +62,8 @@ export class TouchController { e.stopPropagation(); e.stopImmediatePropagation(); this.state = "one finger"; + this.clientX = e.touches[0].clientX; + this.clientY = e.touches[0].clientY; this.lastTouches = e.touches; this.elRect = this.el.getBoundingClientRect(); } else { @@ -71,6 +72,8 @@ export class TouchController { } else if (e.touches.length == 2) { this.state = "two fingers"; this.lastTouches = e.touches; + this.clientX = (e.touches[0].clientX + e.touches[1].clientX) / 2; + this.clientY = (e.touches[0].clientY + e.touches[1].clientY) / 2; } if (stateWas === "idle" && stateWas !== this.state) { this.onZoomStart(); @@ -92,12 +95,9 @@ export class TouchController { const ts_old = this.lastTouches!; this.lastTouches = e.touches; const ts_new = e.touches; - const height = this.elRect?.height || 500; - const dist = (ts_new[0].clientY - ts_old[0].clientY) / height; - let zoom = 1; - if (dist > 0) zoom = 1 + dist * 4; - if (dist < 0) zoom = 1 / (1 - dist * 4); - await this.handleZoom(zoom); + const dist = ts_new[0].clientY - ts_old[0].clientY; + + await this.handleZoom(dist); } async handleTwoFingersZoom(e: TouchEvent) { e.preventDefault(); @@ -114,21 +114,17 @@ export class TouchController { (ts_new[0].clientX - ts_new[1].clientX) ** 2 + (ts_new[0].clientY - ts_new[1].clientY) ** 2 ); - await this.handleZoom(spread_new / spread_old); + await this.handleZoom(spread_new - spread_old); } - async handleZoom(zoom: number) { - const oldLayout = this.el.layout; - const layout = {}; - const axes = Array.from({ length: 30 }).flatMap((_, i) => { - const i_str = i === 0 ? "" : i + 1; - return [`xaxis${i_str}`, `yaxis${i_str}`]; + async handleZoom(dist: number) { + const wheelEvent = new WheelEvent("wheel", { + clientX: this.clientX, + clientY: this.clientY, + deltaX: 0, + deltaY: -dist, }); - for (const axis of axes) { - if (!oldLayout[axis]?.fixedrange) { - layout[`${axis}.range`] = zoomedRange(oldLayout[axis], zoom); - } - } - this.onZoom(layout); + + this.el.querySelector(".nsewdrag.drag")!.dispatchEvent(wheelEvent); } onTouchEnd = () => { diff --git a/src/types.ts b/src/types.ts index 57107a4..392ba7b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,7 @@ -import { ColorSchemeArray, ColorSchemeNames } from "./color-schemes"; +import { + ColorSchemeArray, + ColorSchemeNames, +} from "./parse-config/parse-color-scheme"; import { TimeDurationStr } from "./duration/duration"; import { AutoPeriodConfig, @@ -42,8 +45,8 @@ export type InputConfig = { }; layout?: Partial; config?: Partial; - no_theme?: boolean; - no_default_layout?: boolean; + ha_theme?: boolean; + raw_plotly_config?: boolean; significant_changes_only?: boolean; // defaults to false minimal_response?: boolean; // defaults to true disable_pinch_to_zoom?: boolean; // defaults to false @@ -78,11 +81,12 @@ export type Config = { entities: EntityConfig[]; layout: Partial; config: Partial; - no_theme: boolean; - no_default_layout: boolean; + ha_theme: boolean; + raw_plotly_config: boolean; significant_changes_only: boolean; minimal_response: boolean; disable_pinch_to_zoom: boolean; + visible_range: [number, number]; }; export type EntityIdStateConfig = { entity: string; @@ -95,7 +99,6 @@ export type EntityIdStatisticsConfig = { entity: string; statistic: StatisticType; period: StatisticPeriod; - autoPeriod: AutoPeriodConfig; }; export type EntityIdConfig = | EntityIdStateConfig @@ -143,11 +146,3 @@ export type EntityData = { }; export type TimestampRange = Timestamp[]; // [Timestamp, Timestamp]; - -export type HATheme = { - "--card-background-color": string; - "--primary-background-color": string; - "--primary-color": string; - "--primary-text-color": string; - "--secondary-text-color": string; -}; diff --git a/src/utils.ts b/src/utils.ts index 0b3aa9d..89eca72 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,32 +3,31 @@ export const sleep = (ms: number) => export function getIsPureObject(val: any) { return typeof val === "object" && val !== null && !Array.isArray(val); } -export class Deferred { - resolve: (value: R | PromiseLike) => void = () => {}; - reject = () => {}; - promise: Promise; - constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } -} -export function debounce) => any>( - func: F -): (...args: Parameters) => Promise> { - let timeout: number; - let deferred = new Deferred>(); - return (...args: Parameters): Promise> => { - const oldDeferred = deferred; - deferred = new Deferred>(); - cancelAnimationFrame(timeout); - timeout = requestAnimationFrame(async () => { - const r = await func(...args); - deferred.resolve(r); - oldDeferred.resolve(r); - }); - return deferred.promise; +export function debounce(func: (delay?: number) => Promise) { + let lastRunningPromise = Promise.resolve(); + let waiting = { + cancelled: false, + }; + return (delay?: number) => { + waiting.cancelled = true; + const me = { + cancelled: false, + }; + waiting = me; + return (lastRunningPromise = lastRunningPromise + .catch(() => {}) + .then( + () => + new Promise(async (resolve) => { + if (delay) { + await sleep(delay); + } + requestAnimationFrame(async () => { + if (me.cancelled) resolve(); + else resolve(func()); + }); + }) + )); }; }