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
-
+
-
+
-
+
-
+
@@ -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());
+ });
+ })
+ ));
};
}