From f04fd2364811799738be1fde10025444d80ccbe9 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 17 Apr 2025 10:55:24 +0200 Subject: [PATCH 1/8] chart work --- package-lock.json | 668 +++++++++++++++++- package.json | 3 + src/analytics/canvas.ts | 25 + .../periodReport/cohortRetentionReport.ts | 193 ++++- src/analytics/reports/reports.ts | 6 +- src/discord-bot.ts | 14 +- 6 files changed, 881 insertions(+), 28 deletions(-) create mode 100644 src/analytics/canvas.ts diff --git a/package-lock.json b/package-lock.json index 8dd3747..c08905d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "async-retry": "^1.3.3", "axios": "^1.8.4", + "chart.js": "^4.4.9", + "chartjs-chart-matrix": "^3.0.0", + "chartjs-node-canvas": "^5.0.0", "cli-table": "^0.3.11", "discord.js": "^12.5.1", "dotenv": "^16.5.0", @@ -175,6 +178,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -667,6 +676,37 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -690,6 +730,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -713,6 +777,20 @@ "node": ">=6" } }, + "node_modules/canvas": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", + "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -729,6 +807,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-chart-matrix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.0.tgz", + "integrity": "sha512-lUWC1UaWkxGdG02dBJ5r1ppbSYB/uWmwAh11VEs7V3ZQItNCk4am+rmacwkgeb+SQeEj2hP9Qq4oGsUmPl/1lQ==", + "license": "MIT", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, + "node_modules/chartjs-node-canvas": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chartjs-node-canvas/-/chartjs-node-canvas-5.0.0.tgz", + "integrity": "sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==", + "license": "MIT", + "dependencies": { + "canvas": "^3.1.0", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "chart.js": "^4.4.8" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -915,6 +1033,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -930,6 +1072,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1013,6 +1164,15 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -1294,6 +1454,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1463,6 +1632,12 @@ "node": ">= 6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1541,6 +1716,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1702,6 +1883,26 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1773,6 +1974,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inspirational-quotes": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/inspirational-quotes/-/inspirational-quotes-1.0.8.tgz", @@ -2206,6 +2413,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2218,6 +2437,21 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -2232,12 +2466,36 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -2305,7 +2563,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -2456,6 +2713,32 @@ "node": ">=0.10" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2518,6 +2801,16 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2548,6 +2841,30 @@ } ] }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -2676,7 +2993,6 @@ "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2725,6 +3041,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -2902,6 +3263,34 @@ "node": ">=8" } }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -2950,6 +3339,24 @@ "typescript": ">=4.2.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -3169,8 +3576,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { "version": "7.5.10", @@ -3312,6 +3718,11 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3642,6 +4053,21 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3661,6 +4087,15 @@ "fill-range": "^7.1.1" } }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3676,6 +4111,15 @@ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true }, + "canvas": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.0.tgz", + "integrity": "sha512-tTj3CqqukVJ9NgSahykNwtGda7V33VLObwrHfzT0vqJXu7J4d4C/7kQQW3fOEGDfZZoILPut5H00gOjyttPGyg==", + "requires": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1" + } + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3686,6 +4130,34 @@ "supports-color": "^7.1.0" } }, + "chart.js": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, + "chartjs-chart-matrix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.0.tgz", + "integrity": "sha512-lUWC1UaWkxGdG02dBJ5r1ppbSYB/uWmwAh11VEs7V3ZQItNCk4am+rmacwkgeb+SQeEj2hP9Qq4oGsUmPl/1lQ==", + "requires": {} + }, + "chartjs-node-canvas": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chartjs-node-canvas/-/chartjs-node-canvas-5.0.0.tgz", + "integrity": "sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==", + "requires": { + "canvas": "^3.1.0", + "tslib": "^2.8.1" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, "cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3824,6 +4296,19 @@ "ms": "^2.1.3" } }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "requires": { + "mimic-response": "^3.1.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3835,6 +4320,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3894,6 +4384,14 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, "environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4081,6 +4579,11 @@ } } }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4207,6 +4710,11 @@ "mime-types": "^2.1.12" } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4256,6 +4764,11 @@ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4352,6 +4865,11 @@ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4404,6 +4922,11 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, "inspirational-quotes": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/inspirational-quotes/-/inspirational-quotes-1.0.8.tgz", @@ -4694,6 +5217,11 @@ "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4703,6 +5231,16 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, "moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -4713,12 +5251,30 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "requires": { + "semver": "^7.3.5" + } + }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" + }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -4758,7 +5314,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "requires": { "wrappy": "1" } @@ -4857,6 +5412,25 @@ "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", "dev": true }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4885,6 +5459,15 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4897,6 +5480,24 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" + } + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -4977,8 +5578,7 @@ "semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==" }, "setimmediate": { "version": "1.0.5", @@ -5006,6 +5606,21 @@ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -5120,6 +5735,29 @@ "has-flag": "^4.0.0" } }, + "tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -5157,6 +5795,19 @@ "dev": true, "requires": {} }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -5317,8 +5968,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { "version": "7.5.10", diff --git a/package.json b/package.json index 5b47d6b..d280cfb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "dependencies": { "async-retry": "^1.3.3", "axios": "^1.8.4", + "chart.js": "^4.4.9", + "chartjs-chart-matrix": "^3.0.0", + "chartjs-node-canvas": "^5.0.0", "cli-table": "^0.3.11", "discord.js": "^12.5.1", "dotenv": "^16.5.0", diff --git a/src/analytics/canvas.ts b/src/analytics/canvas.ts new file mode 100644 index 0000000..f6d013e --- /dev/null +++ b/src/analytics/canvas.ts @@ -0,0 +1,25 @@ +import { Chart, ChartConfiguration } from "chart.js"; +import { MatrixController, MatrixElement } from "chartjs-chart-matrix"; +import { ChartJSNodeCanvas, MimeType } from "chartjs-node-canvas"; + +// init +Chart.register(MatrixController, MatrixElement); + +// NOTE: +// we want to reuse canvases because of memory management +// if we want different sizes we need to create multiple canvases + +const canvasConfiguration = { + width: 800, + height: 600, + backgroundColour: "white", +} as const; + +const canvas = new ChartJSNodeCanvas(canvasConfiguration); + +export function renderChart( + configuration: ChartConfiguration, + mimeType?: MimeType, +): Promise { + return canvas.renderToBuffer(configuration, mimeType); +} diff --git a/src/analytics/reports/periodReport/cohortRetentionReport.ts b/src/analytics/reports/periodReport/cohortRetentionReport.ts index 94f8271..ba3ac35 100644 --- a/src/analytics/reports/periodReport/cohortRetentionReport.ts +++ b/src/analytics/reports/periodReport/cohortRetentionReport.ts @@ -1,4 +1,8 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ChartConfiguration, ChartDataset, Plugin } from "chart.js"; +import { MatrixDataPoint } from "chartjs-chart-matrix"; import { Moment } from "moment"; +import { renderChart } from "../../../charts/canvas"; import { PosthogEvent } from "../../events"; import { groupEventsByExecutionEnv } from "../../executionEnvs"; import { createCrossTable } from "../../table"; @@ -23,23 +27,6 @@ export async function generateCohortRetentionReport( const cohorts = createUserActivityCohorts(events, periods); - /** - * @param {[number]} cohort [num_users_at_start, num_users_after_1_period, ...] - * @returns {[string]} [num_users_at_start, num_and_perc_users_after_1_period, ...] - * Examples of returned value: - * - `["10", "6 (60%)", "3 (30%)", "0 (0%)"]` - * - `["0", "N/A", "N/A"]` - */ - function calcCohortRetentionTableRow(cohort: number[]): string[] { - const [numUsersAtStart, ...numUsersThroughPeriods] = cohort; - const retentionPercentages = numUsersThroughPeriods.map((n) => - numUsersAtStart === 0 - ? "N/A" - : `${n} (${Math.round((n / numUsersAtStart) * 100)}%)`, - ); - return [numUsersAtStart.toString(), ...retentionPercentages]; - } - const periodNameShort = periodName[0]; const table = createCrossTable({ head: ["", ...periods.map((_, i) => `+${i}${periodNameShort}`)], @@ -64,6 +51,11 @@ export async function generateCohortRetentionReport( lastPeriod[0], )} - ${fmt(lastPeriod[1])}`, ], + localChart: await createCohortRetentionHeatMap( + cohorts, + periods, + periodName, + ), }; return report; } @@ -131,3 +123,170 @@ function createUniqueUsersCohort( }), ]; } + +/** + * @param cohort [num_users_at_start, num_users_after_1_period, ...] + * @returns [num_users_at_start, num_and_perc_users_after_1_period, ...] + * + * Examples of returned value: + * - `["10", "6 (60%)", "3 (30%)", "0 (0%)"]` + * - `["0", "N/A", "N/A"]` + */ +function calcCohortRetentionTableRow(cohort: number[]): string[] { + const [numUsersAtStart, ...numUsersThroughPeriods] = cohort; + const numUsersWithRetentionPercentagesThroughPeriods = + numUsersThroughPeriods.map((n) => + numUsersAtStart === 0 + ? "N/A" + : `${n} (${Math.round((n / numUsersAtStart) * 100)}%)`, + ); + return [ + numUsersAtStart.toString(), + ...numUsersWithRetentionPercentagesThroughPeriods, + ]; +} + +async function createCohortRetentionHeatMap( + cohorts: number[][], + periods: Period[], + periodName: PeriodName, +): Promise { + const cellSizePlugin: Plugin<"matrix"> = { + id: "matrix-cell-size", + afterLayout: (chart) => { + const dataset = chart.data.datasets[0]; + const chartArea = chart.chartArea; + + if (!chartArea) return; + + const cellWidth = chartArea.width / periods.length; + const cellHeight = chartArea.height / cohorts.length; + + dataset.width = () => cellWidth - 1; + dataset.height = () => cellHeight - 1; + }, + }; + + const matrixLabelPlugin: Plugin<"matrix"> = { + id: "matrix-cell-labels", + afterDatasetsDraw: (chart) => { + const ctx = chart.ctx; + const dataset = chart.data.datasets[0] as ChartDataset< + "matrix", + (MatrixDataPoint & { value: number })[] + >; + const meta = chart.getDatasetMeta(0); + + dataset.data.forEach((dataPoint, index) => { + const rect = meta.data[index]; + if (!rect) return; + + const { x, y, width, height } = rect.getProps( + ["x", "y", "width", "height"], + true, + ); + + const baseValue = cohorts[dataPoint.y][0]; + const percent = + baseValue > 0 ? Math.round((dataPoint.value / baseValue) * 100) : 0; + const label = `${percent}%`; + + ctx.save(); + ctx.fillStyle = "black"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, x + width / 2, y + height / 2); + ctx.restore(); + }); + }, + }; + + const chartConfiguration: ChartConfiguration = { + type: "matrix", + data: { + datasets: [ + { + data: cohorts.flatMap((cohort, cohortIndex) => { + return cohort.map((value, periodIndex) => ({ + x: periodIndex, + y: cohortIndex, + value: value, + })); + }), + label: "Cohort Retention", + backgroundColor: (context: any) => { + const data = context.dataset.data[context.dataIndex]; + const alpha = data.value / cohorts[data.y][0]; + return `rgba(54, 162, 235, ${alpha})`; // Blue-ish color with varying alpha + }, + borderColor: "rgba(0, 0, 0, 0.1)", + borderWidth: 1, + }, + ], + }, + options: { + aspectRatio: 1, + scales: { + x: { + type: "linear", + min: 0, + max: periods.length - 1, + ticks: { + stepSize: 1, + callback: (value) => `+${value}`, + font: { size: 10 }, + }, + offset: true, + title: { + display: true, + text: `Cohort Progression (per ${periodName})`, + font: { + size: 12, + weight: "bold", + }, + }, + }, + y: { + type: "linear", + min: 0, + max: cohorts.length - 1, + ticks: { + stepSize: 1, + callback: (value) => `#${value}`, + font: { size: 10 }, + }, + offset: true, + title: { + display: true, + text: "Cohort Start", + font: { + size: 12, + weight: "bold", + }, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + title: { + display: true, + text: `Cohort retention chart (per ${periodName})`, + align: "center", + font: { + size: 16, + weight: "bold", + }, + padding: { + top: 10, + bottom: 20, + }, + }, + }, + }, + plugins: [cellSizePlugin, matrixLabelPlugin], + }; + return renderChart(chartConfiguration); +} diff --git a/src/analytics/reports/reports.ts b/src/analytics/reports/reports.ts index 03d8e55..0cbb0d1 100644 --- a/src/analytics/reports/reports.ts +++ b/src/analytics/reports/reports.ts @@ -13,7 +13,11 @@ export interface ChartReport { chart: ImageCharts; } -export type CohortRetentionReport = TextReport; +export interface LocalChartReport { + localChart: Buffer; +} + +export type CohortRetentionReport = TextReport & LocalChartReport; export type ProjectsReport = TextReport & CsvReport; export type UserActivityReport = TextReport & CsvReport & ChartReport; export type TotalUniqueReport = TextReport; diff --git a/src/discord-bot.ts b/src/discord-bot.ts index 5b1c5d5..fffeab7 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -9,6 +9,11 @@ import { PosthogEvent } from "./analytics/events"; import moment from "./analytics/moment"; import * as reports from "./analytics/reports"; import { ChartReport, TextReport } from "./analytics/reports/reports"; +import { + ChartReport, + LocalChartReport, + TextReport, +} from "./analytics/reports/reports"; import logger from "./utils/logger"; dotenvConfig(); @@ -257,7 +262,7 @@ async function sendAnalyticsReport( } function covertSimpleReportToDiscordMessage( - report: Partial, + report: Partial, ): Discord.MessageOptions { const options: Discord.MessageOptions = {}; if (report.text) { @@ -280,6 +285,13 @@ function covertSimpleReportToDiscordMessage( options.embed = embed; } + if (report.localChart) { + if (!options.files) { + options.files = []; + } + options.files.push(new Discord.MessageAttachment(report.localChart)); + } + return options; } From 6bbddc468c1303714f18ba512d236f0faea45a62 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 13:27:46 +0200 Subject: [PATCH 2/8] better colors and visualization --- src/analytics/color.ts | 95 +++++++++++++++++++ .../periodReport/cohortRetentionReport.ts | 36 +++++-- 2 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 src/analytics/color.ts diff --git a/src/analytics/color.ts b/src/analytics/color.ts new file mode 100644 index 0000000..cbd3f19 --- /dev/null +++ b/src/analytics/color.ts @@ -0,0 +1,95 @@ +/** + * Creates a color interpolator function. + * @param colors - Array of hex color codes to interpolate between + * @returns A function that accepts a percentage (0-1) and returns the interpolated color. + */ +export function createColorInterpolator( + colors: string[], +): (value: number) => string { + if (colors.length === 0) { + throw new Error("At least one color must be provided."); + } + + if (colors.length === 1) { + const color = colors[0]; + return () => color; + } + + if (colors.length === 2) { + return createTwoColorInterpolator(colors[0], colors[1]); + } + + // Multiple colors interpolation + const segmentCount = colors.length - 1; + return (value: number) => { + if (value <= 0) return colors[0]; + if (value >= 1) return colors[colors.length - 1]; + + const segment = Math.floor(value * segmentCount); + const segmentValue = (value * segmentCount) % 1; + + return createTwoColorInterpolator( + colors[segment], + colors[segment + 1], + )(segmentValue); + }; +} + +/** + * Creates a color interpolator function between two colors. + * @param color1 - Starting hex color code + * @param color2 - Ending hex color code + * @returns A function that accepts a percentage (0-1) and returns the interpolated color. + */ +function createTwoColorInterpolator( + color1: string, + color2: string, +): (value: number) => string { + const [r1, g1, b1] = hexToRgb(color1); + const [r2, g2, b2] = hexToRgb(color2); + + return (value: number) => { + const r = r1 + (r2 - r1) * value; + const g = g1 + (g2 - g1) * value; + const b = b1 + (b2 - b1) * value; + + return rgbToHex(r, g, b); + }; +} + +/** + * Converts a hex color string to RGB components + * @param hex - Hex color code (with or without # prefix) + * @returns RGB values as numbers + */ +function hexToRgb(hex: string): [number, number, number] { + // Remove # if present + const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex; + + // Parse hex values to RGB + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + return [r, g, b]; +} + +/** + * Converts RGB components to a hex color string + * @param r - Red component (0-255) + * @param g - Green component (0-255) + * @param b - Blue component (0-255) + * @returns Hex color code with # prefix + */ +function rgbToHex(r: number, g: number, b: number): string { + const roundedR = Math.round(r); + const roundedG = Math.round(g); + const roundedB = Math.round(b); + + return ( + "#" + + roundedR.toString(16).padStart(2, "0") + + roundedG.toString(16).padStart(2, "0") + + roundedB.toString(16).padStart(2, "0") + ); +} diff --git a/src/analytics/reports/periodReport/cohortRetentionReport.ts b/src/analytics/reports/periodReport/cohortRetentionReport.ts index ba3ac35..48e696c 100644 --- a/src/analytics/reports/periodReport/cohortRetentionReport.ts +++ b/src/analytics/reports/periodReport/cohortRetentionReport.ts @@ -186,10 +186,17 @@ async function createCohortRetentionHeatMap( true, ); - const baseValue = cohorts[dataPoint.y][0]; - const percent = - baseValue > 0 ? Math.round((dataPoint.value / baseValue) * 100) : 0; - const label = `${percent}%`; + let label: string; + if (dataPoint.x === 0) { + label = dataPoint.value.toString(); + } else { + const initialValue = cohorts[dataPoint.y][0]; + const percent = + initialValue > 0 + ? Math.round((dataPoint.value / initialValue) * 100) + : 0; + label = `${percent}%`; + } ctx.save(); ctx.fillStyle = "black"; @@ -202,6 +209,16 @@ async function createCohortRetentionHeatMap( }, }; + const maximumPercentageAfterInitialCohortSize: number = Math.max( + ...cohorts.flatMap((cohort) => + cohort.map((value) => value / cohort[0]).slice(1), + ), + ); + const colorInterpolator = createColorInterpolator([ + "#f04a63", + "#FFA071", + "#FFEE8C", + ]); const chartConfiguration: ChartConfiguration = { type: "matrix", data: { @@ -212,13 +229,20 @@ async function createCohortRetentionHeatMap( x: periodIndex, y: cohortIndex, value: value, + percentage: value / cohort[0], })); }), label: "Cohort Retention", backgroundColor: (context: any) => { const data = context.dataset.data[context.dataIndex]; - const alpha = data.value / cohorts[data.y][0]; - return `rgba(54, 162, 235, ${alpha})`; // Blue-ish color with varying alpha + + if (data.x === 0) { + return "#93e693"; + } + + const interpolation = + data.percentage / maximumPercentageAfterInitialCohortSize; + return colorInterpolator(interpolation); }, borderColor: "rgba(0, 0, 0, 0.1)", borderWidth: 1, From ed5190dbf19e680c496ef2872818f2b37c240279 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 13:46:56 +0200 Subject: [PATCH 3/8] logic cleanup --- .../periodReport/cohortRetentionReport.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/analytics/reports/periodReport/cohortRetentionReport.ts b/src/analytics/reports/periodReport/cohortRetentionReport.ts index 48e696c..7c0a683 100644 --- a/src/analytics/reports/periodReport/cohortRetentionReport.ts +++ b/src/analytics/reports/periodReport/cohortRetentionReport.ts @@ -146,22 +146,28 @@ function calcCohortRetentionTableRow(cohort: number[]): string[] { ]; } +type CohortRetentionHeatMapPoint = MatrixDataPoint & { + value: number; + percentage: number; +}; + async function createCohortRetentionHeatMap( cohorts: number[][], periods: Period[], periodName: PeriodName, ): Promise { + // chartArea is undefined before the chart is laid out + // so we can't calculate matrix cell size during initial data calculation const cellSizePlugin: Plugin<"matrix"> = { id: "matrix-cell-size", afterLayout: (chart) => { const dataset = chart.data.datasets[0]; const chartArea = chart.chartArea; - if (!chartArea) return; - const cellWidth = chartArea.width / periods.length; const cellHeight = chartArea.height / cohorts.length; + // -1 stops overlaps dataset.width = () => cellWidth - 1; dataset.height = () => cellHeight - 1; }, @@ -173,7 +179,7 @@ async function createCohortRetentionHeatMap( const ctx = chart.ctx; const dataset = chart.data.datasets[0] as ChartDataset< "matrix", - (MatrixDataPoint & { value: number })[] + CohortRetentionHeatMapPoint[] >; const meta = chart.getDatasetMeta(0); @@ -219,7 +225,10 @@ async function createCohortRetentionHeatMap( "#FFA071", "#FFEE8C", ]); - const chartConfiguration: ChartConfiguration = { + const chartConfiguration: ChartConfiguration< + "matrix", + CohortRetentionHeatMapPoint[] + > = { type: "matrix", data: { datasets: [ From 7dba057dd968345f44cd4b5fba4758634efe9c96 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 13:48:29 +0200 Subject: [PATCH 4/8] imports --- src/analytics/reports/periodReport/cohortRetentionReport.ts | 3 ++- src/discord-bot.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/analytics/reports/periodReport/cohortRetentionReport.ts b/src/analytics/reports/periodReport/cohortRetentionReport.ts index 7c0a683..46054b4 100644 --- a/src/analytics/reports/periodReport/cohortRetentionReport.ts +++ b/src/analytics/reports/periodReport/cohortRetentionReport.ts @@ -2,7 +2,8 @@ import { ChartConfiguration, ChartDataset, Plugin } from "chart.js"; import { MatrixDataPoint } from "chartjs-chart-matrix"; import { Moment } from "moment"; -import { renderChart } from "../../../charts/canvas"; +import { renderChart } from "../../canvas"; +import { createColorInterpolator } from "../../color"; import { PosthogEvent } from "../../events"; import { groupEventsByExecutionEnv } from "../../executionEnvs"; import { createCrossTable } from "../../table"; diff --git a/src/discord-bot.ts b/src/discord-bot.ts index fffeab7..7fbec61 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -8,7 +8,6 @@ import { getAnalyticsErrorMessage } from "./analytics/errors"; import { PosthogEvent } from "./analytics/events"; import moment from "./analytics/moment"; import * as reports from "./analytics/reports"; -import { ChartReport, TextReport } from "./analytics/reports/reports"; import { ChartReport, LocalChartReport, From 0d2b1312fa7e49ea89927ae182da9fe21fec3334 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 15:05:11 +0200 Subject: [PATCH 5/8] new colors --- src/analytics/color.ts | 95 ------------------- .../periodReport/cohortRetentionReport.ts | 19 ++-- 2 files changed, 8 insertions(+), 106 deletions(-) delete mode 100644 src/analytics/color.ts diff --git a/src/analytics/color.ts b/src/analytics/color.ts deleted file mode 100644 index cbd3f19..0000000 --- a/src/analytics/color.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Creates a color interpolator function. - * @param colors - Array of hex color codes to interpolate between - * @returns A function that accepts a percentage (0-1) and returns the interpolated color. - */ -export function createColorInterpolator( - colors: string[], -): (value: number) => string { - if (colors.length === 0) { - throw new Error("At least one color must be provided."); - } - - if (colors.length === 1) { - const color = colors[0]; - return () => color; - } - - if (colors.length === 2) { - return createTwoColorInterpolator(colors[0], colors[1]); - } - - // Multiple colors interpolation - const segmentCount = colors.length - 1; - return (value: number) => { - if (value <= 0) return colors[0]; - if (value >= 1) return colors[colors.length - 1]; - - const segment = Math.floor(value * segmentCount); - const segmentValue = (value * segmentCount) % 1; - - return createTwoColorInterpolator( - colors[segment], - colors[segment + 1], - )(segmentValue); - }; -} - -/** - * Creates a color interpolator function between two colors. - * @param color1 - Starting hex color code - * @param color2 - Ending hex color code - * @returns A function that accepts a percentage (0-1) and returns the interpolated color. - */ -function createTwoColorInterpolator( - color1: string, - color2: string, -): (value: number) => string { - const [r1, g1, b1] = hexToRgb(color1); - const [r2, g2, b2] = hexToRgb(color2); - - return (value: number) => { - const r = r1 + (r2 - r1) * value; - const g = g1 + (g2 - g1) * value; - const b = b1 + (b2 - b1) * value; - - return rgbToHex(r, g, b); - }; -} - -/** - * Converts a hex color string to RGB components - * @param hex - Hex color code (with or without # prefix) - * @returns RGB values as numbers - */ -function hexToRgb(hex: string): [number, number, number] { - // Remove # if present - const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex; - - // Parse hex values to RGB - const r = parseInt(cleanHex.substring(0, 2), 16); - const g = parseInt(cleanHex.substring(2, 4), 16); - const b = parseInt(cleanHex.substring(4, 6), 16); - - return [r, g, b]; -} - -/** - * Converts RGB components to a hex color string - * @param r - Red component (0-255) - * @param g - Green component (0-255) - * @param b - Blue component (0-255) - * @returns Hex color code with # prefix - */ -function rgbToHex(r: number, g: number, b: number): string { - const roundedR = Math.round(r); - const roundedG = Math.round(g); - const roundedB = Math.round(b); - - return ( - "#" + - roundedR.toString(16).padStart(2, "0") + - roundedG.toString(16).padStart(2, "0") + - roundedB.toString(16).padStart(2, "0") - ); -} diff --git a/src/analytics/reports/periodReport/cohortRetentionReport.ts b/src/analytics/reports/periodReport/cohortRetentionReport.ts index 46054b4..ea6cdf9 100644 --- a/src/analytics/reports/periodReport/cohortRetentionReport.ts +++ b/src/analytics/reports/periodReport/cohortRetentionReport.ts @@ -3,7 +3,6 @@ import { ChartConfiguration, ChartDataset, Plugin } from "chart.js"; import { MatrixDataPoint } from "chartjs-chart-matrix"; import { Moment } from "moment"; import { renderChart } from "../../canvas"; -import { createColorInterpolator } from "../../color"; import { PosthogEvent } from "../../events"; import { groupEventsByExecutionEnv } from "../../executionEnvs"; import { createCrossTable } from "../../table"; @@ -216,16 +215,13 @@ async function createCohortRetentionHeatMap( }, }; + const maximumValue = Math.max(...cohorts.map((cohort) => cohort[0])); const maximumPercentageAfterInitialCohortSize: number = Math.max( ...cohorts.flatMap((cohort) => cohort.map((value) => value / cohort[0]).slice(1), ), ); - const colorInterpolator = createColorInterpolator([ - "#f04a63", - "#FFA071", - "#FFEE8C", - ]); + const chartConfiguration: ChartConfiguration< "matrix", CohortRetentionHeatMapPoint[] @@ -247,12 +243,13 @@ async function createCohortRetentionHeatMap( const data = context.dataset.data[context.dataIndex]; if (data.x === 0) { - return "#93e693"; + const alpha = data.value / maximumValue; + return `rgba(147, 230, 147, ${alpha})`; + } else { + const alpha = + data.percentage / maximumPercentageAfterInitialCohortSize; + return `rgba(147, 176, 230, ${alpha})`; } - - const interpolation = - data.percentage / maximumPercentageAfterInitialCohortSize; - return colorInterpolator(interpolation); }, borderColor: "rgba(0, 0, 0, 0.1)", borderWidth: 1, From 35a483d5690b3e6d0e57cab27bd27781b616101b Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 17:17:49 +0200 Subject: [PATCH 6/8] updates --- .../periodReport/cohortRetentionReport.ts | 202 +++++++++--------- src/{analytics => charts}/canvas.ts | 0 src/charts/color.ts | 171 +++++++++++++++ src/charts/plugins/matrix.ts | 34 +++ 4 files changed, 311 insertions(+), 96 deletions(-) rename src/{analytics => charts}/canvas.ts (100%) create mode 100644 src/charts/color.ts create mode 100644 src/charts/plugins/matrix.ts diff --git a/src/analytics/reports/periodReport/cohortRetentionReport.ts b/src/analytics/reports/periodReport/cohortRetentionReport.ts index ea6cdf9..e6d6ec9 100644 --- a/src/analytics/reports/periodReport/cohortRetentionReport.ts +++ b/src/analytics/reports/periodReport/cohortRetentionReport.ts @@ -1,8 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ChartConfiguration, ChartDataset, Plugin } from "chart.js"; +import { Chart, ChartConfiguration, ChartDataset, Plugin } from "chart.js"; import { MatrixDataPoint } from "chartjs-chart-matrix"; import { Moment } from "moment"; -import { renderChart } from "../../canvas"; +import { renderChart } from "../../../charts/canvas"; +import { + createColorInterpolator, + getFontColorForBackgroundColor, + SEQUENTIAL_BLUE_PALETTE, + SEQUENTIAL_GREEN_PALETTE, +} from "../../../charts/color"; +import { matrixAutoScaleCellSize } from "../../../charts/plugins/matrix"; import { PosthogEvent } from "../../events"; import { groupEventsByExecutionEnv } from "../../executionEnvs"; import { createCrossTable } from "../../table"; @@ -147,8 +154,8 @@ function calcCohortRetentionTableRow(cohort: number[]): string[] { } type CohortRetentionHeatMapPoint = MatrixDataPoint & { - value: number; - percentage: number; + size: number; + retentionPercentage: number; }; async function createCohortRetentionHeatMap( @@ -156,105 +163,61 @@ async function createCohortRetentionHeatMap( periods: Period[], periodName: PeriodName, ): Promise { - // chartArea is undefined before the chart is laid out - // so we can't calculate matrix cell size during initial data calculation - const cellSizePlugin: Plugin<"matrix"> = { - id: "matrix-cell-size", - afterLayout: (chart) => { - const dataset = chart.data.datasets[0]; - const chartArea = chart.chartArea; + const initialPeriodColorInterpolator = createColorInterpolator( + SEQUENTIAL_GREEN_PALETTE, + ); + const afterInitialPeriodColorInterpolator = createColorInterpolator( + SEQUENTIAL_BLUE_PALETTE, + ); - const cellWidth = chartArea.width / periods.length; - const cellHeight = chartArea.height / cohorts.length; + const maxSize = Math.max(...cohorts.map((cohort) => cohort[0])); + const maxCohortRetentionPercentageAfterInitialPeriod: number = Math.max( + ...cohorts.flatMap((cohort) => + cohort.map((value) => value / cohort[0]).slice(1), + ), + ); - // -1 stops overlaps - dataset.width = () => cellWidth - 1; - dataset.height = () => cellHeight - 1; + const chartData: CohortRetentionHeatMapPoint[] = cohorts.flatMap( + (cohort, cohortIndex) => { + return cohort.map((size, periodIndex) => ({ + x: periodIndex, + y: cohortIndex, + size: size, + retentionPercentage: size / cohort[0], + })); }, - }; - - const matrixLabelPlugin: Plugin<"matrix"> = { - id: "matrix-cell-labels", - afterDatasetsDraw: (chart) => { - const ctx = chart.ctx; - const dataset = chart.data.datasets[0] as ChartDataset< - "matrix", - CohortRetentionHeatMapPoint[] - >; - const meta = chart.getDatasetMeta(0); - - dataset.data.forEach((dataPoint, index) => { - const rect = meta.data[index]; - if (!rect) return; + ); - const { x, y, width, height } = rect.getProps( - ["x", "y", "width", "height"], - true, - ); + const chartDataset: ChartDataset<"matrix", CohortRetentionHeatMapPoint[]> = { + data: chartData, + label: "Cohort Retention", + backgroundColor: (context: { + dataset: ChartDataset<"matrix", CohortRetentionHeatMapPoint[]>; + dataIndex: number; + }) => { + const point = context.dataset.data[context.dataIndex]; - let label: string; - if (dataPoint.x === 0) { - label = dataPoint.value.toString(); - } else { - const initialValue = cohorts[dataPoint.y][0]; - const percent = - initialValue > 0 - ? Math.round((dataPoint.value / initialValue) * 100) - : 0; - label = `${percent}%`; - } - - ctx.save(); - ctx.fillStyle = "black"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(label, x + width / 2, y + height / 2); - ctx.restore(); - }); + if (point.x === 0) { + const ratio = point.size / maxSize; + return initialPeriodColorInterpolator(ratio); + } else { + const ratio = + point.retentionPercentage / + maxCohortRetentionPercentageAfterInitialPeriod; + return afterInitialPeriodColorInterpolator(ratio); + } }, + borderColor: "rgba(0, 0, 0, 0.1)", + borderWidth: 1, }; - const maximumValue = Math.max(...cohorts.map((cohort) => cohort[0])); - const maximumPercentageAfterInitialCohortSize: number = Math.max( - ...cohorts.flatMap((cohort) => - cohort.map((value) => value / cohort[0]).slice(1), - ), - ); - const chartConfiguration: ChartConfiguration< "matrix", CohortRetentionHeatMapPoint[] > = { type: "matrix", data: { - datasets: [ - { - data: cohorts.flatMap((cohort, cohortIndex) => { - return cohort.map((value, periodIndex) => ({ - x: periodIndex, - y: cohortIndex, - value: value, - percentage: value / cohort[0], - })); - }), - label: "Cohort Retention", - backgroundColor: (context: any) => { - const data = context.dataset.data[context.dataIndex]; - - if (data.x === 0) { - const alpha = data.value / maximumValue; - return `rgba(147, 230, 147, ${alpha})`; - } else { - const alpha = - data.percentage / maximumPercentageAfterInitialCohortSize; - return `rgba(147, 176, 230, ${alpha})`; - } - }, - borderColor: "rgba(0, 0, 0, 0.1)", - borderWidth: 1, - }, - ], + datasets: [chartDataset], }, options: { aspectRatio: 1, @@ -266,14 +229,14 @@ async function createCohortRetentionHeatMap( ticks: { stepSize: 1, callback: (value) => `+${value}`, - font: { size: 10 }, + font: { size: 12 }, }, offset: true, title: { display: true, text: `Cohort Progression (per ${periodName})`, font: { - size: 12, + size: 14, weight: "bold", }, }, @@ -285,14 +248,14 @@ async function createCohortRetentionHeatMap( ticks: { stepSize: 1, callback: (value) => `#${value}`, - font: { size: 10 }, + font: { size: 12 }, }, offset: true, title: { display: true, text: "Cohort Start", font: { - size: 12, + size: 14, weight: "bold", }, }, @@ -307,17 +270,64 @@ async function createCohortRetentionHeatMap( text: `Cohort retention chart (per ${periodName})`, align: "center", font: { - size: 16, + size: 20, weight: "bold", }, padding: { top: 10, - bottom: 20, + bottom: 10, }, }, }, }, - plugins: [cellSizePlugin, matrixLabelPlugin], + plugins: [matrixAutoScaleCellSize, cohortRetentionChartLabels], }; return renderChart(chartConfiguration); } + +const cohortRetentionChartLabels: Plugin<"matrix"> = { + id: "matrix-cell-labels", + afterDatasetsDraw: (chart) => { + const canvasContext = chart.ctx; + const cohortRetentionChart = chart as Chart< + "matrix", + CohortRetentionHeatMapPoint[] + >; + + cohortRetentionChart.data.datasets.forEach((dataset, index) => { + const meta = cohortRetentionChart.getDatasetMeta(index); + + dataset.data.forEach((dataPoint, index) => { + const rect = meta.data[index]; + if (!rect) return; + + const { x, y, width, height } = rect.getProps( + ["x", "y", "width", "height"], + true, + ); + + let label: string; + if (dataPoint.x === 0) { + label = dataPoint.size.toString(); + } else { + label = `${Math.round(dataPoint.retentionPercentage * 100)}%`; + } + + // Get the background color of the cell + const backgroundColor = (dataset.backgroundColor as any)({ + dataset, + dataIndex: index, + }); + + canvasContext.save(); + canvasContext.fillStyle = + getFontColorForBackgroundColor(backgroundColor); + canvasContext.font = "12px sans-serif"; + canvasContext.textAlign = "center"; + canvasContext.textBaseline = "middle"; + canvasContext.fillText(label, x + width / 2, y + height / 2); + canvasContext.restore(); + }); + }); + }, +}; diff --git a/src/analytics/canvas.ts b/src/charts/canvas.ts similarity index 100% rename from src/analytics/canvas.ts rename to src/charts/canvas.ts diff --git a/src/charts/color.ts b/src/charts/color.ts new file mode 100644 index 0000000..ef4fd2c --- /dev/null +++ b/src/charts/color.ts @@ -0,0 +1,171 @@ +// Palettes source https://mk.bcgsc.ca/brewer/swatches/brewer.txt +export const SEQUENTIAL_BLUE_PALETTE = [ + "rgb(239, 243, 255)", + "rgb(198, 219, 239)", + "rgb(158, 202, 225)", + "rgb(107, 174, 214)", + "rgb(66, 146, 198)", + "rgb(33, 113, 181)", + "rgb(8, 69, 148)", +]; + +export const SEQUENTIAL_GREEN_PALETTE = [ + "rgb(237, 248, 233)", + "rgb(199, 233, 192)", + "rgb(161, 217, 155)", + "rgb(116, 196, 118)", + "rgb(65, 171, 93)", + "rgb(35, 139, 69)", + "rgb(0, 90, 50)", +]; + +export function getFontColorForBackgroundColor( + backgroundColor: string, +): string { + return isDarkColor(backgroundColor) ? "white" : "black"; +} + +export function isDarkColor(color: string): boolean { + const luminance = calculateLuminance(color); + return luminance < 0.35; +} + +/** + * Calculates the luminance of an RGB color. + * https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ +export function calculateLuminance(color: string): number { + const rgb = colorToRgb(color); + const a = rgb.map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; +} + +/** + * Creates a color interpolator function. + * @param colors - Array of hex color codes to interpolate between + * @returns A function that accepts a percentage (0-1) and returns the interpolated color. + */ +export function createColorInterpolator( + colors: string[], +): (value: number) => string { + if (colors.length === 0) { + throw new Error("At least one color must be provided."); + } + + if (colors.length === 1) { + const color = colors[0]; + return () => color; + } + + if (colors.length === 2) { + return createTwoColorInterpolator(colors[0], colors[1]); + } + + // Multiple colors interpolation + const segmentCount = colors.length - 1; + return (value: number) => { + if (value <= 0) return colors[0]; + if (value >= 1) return colors[colors.length - 1]; + + const segment = Math.floor(value * segmentCount); + const segmentValue = (value * segmentCount) % 1; + + return createTwoColorInterpolator( + colors[segment], + colors[segment + 1], + )(segmentValue); + }; +} + +/** + * Creates a color interpolator function between two colors. + * @param color1 - Starting hex color code + * @param color2 - Ending hex color code + * @returns A function that accepts a percentage (0-1) and returns the interpolated color. + */ +function createTwoColorInterpolator( + color1: string, + color2: string, +): (value: number) => string { + const [r1, g1, b1] = colorToRgb(color1); + const [r2, g2, b2] = colorToRgb(color2); + + return (value: number) => { + const r = r1 + (r2 - r1) * value; + const g = g1 + (g2 - g1) * value; + const b = b1 + (b2 - b1) * value; + + return rgbToHex(r, g, b); + }; +} + +/** + * Converts a color string (hex or rgb) to RGB components + * @param color - Color code (hex or rgb) + * @returns RGB values as numbers + */ +function colorToRgb(color: string): [number, number, number] { + if (typeof color !== "string") { + throw new Error(`Expected a string, but received: ${typeof color}`); + } + + if (color.startsWith("#")) { + return hexToRgb(color); + } else if (color.startsWith("rgb")) { + return rgbStringToRgb(color); + } else { + throw new Error(`Unsupported color format: ${color}`); + } +} + +/** + * Converts a hex color string to RGB components + * @param hex - Hex color code (with or without # prefix) + * @returns RGB values as numbers + */ +function hexToRgb(hex: string): [number, number, number] { + // Remove # if present + const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex; + + // Parse hex values to RGB + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + return [r, g, b]; +} + +/** + * Converts an rgb color string to RGB components + * @param rgb - rgb color code + * @returns RGB values as numbers + */ +function rgbStringToRgb(rgb: string): [number, number, number] { + const cleanRgb = rgb.substring(4, rgb.length - 1); + const [r, g, b] = cleanRgb.split(",").map(Number); + + return [r, g, b]; +} + +/** + * Converts RGB components to a hex color string + * @param r - Red component (0-255) + * @param g - Green component (0-255) + * @param b - Blue component (0-255) + * @returns Hex color code with # prefix + */ +function rgbToHex(r: number, g: number, b: number): string { + const roundedR = Math.round(r); + const roundedG = Math.round(g); + const roundedB = Math.round(b); + + return ( + "#" + + roundedR.toString(16).padStart(2, "0") + + roundedG.toString(16).padStart(2, "0") + + roundedB.toString(16).padStart(2, "0") + ); +} diff --git a/src/charts/plugins/matrix.ts b/src/charts/plugins/matrix.ts new file mode 100644 index 0000000..66ffed5 --- /dev/null +++ b/src/charts/plugins/matrix.ts @@ -0,0 +1,34 @@ +import { Plugin } from "chart.js"; + +/** + * @name matrixAutoScaleCellSize + * @description A Chart.js plugin that dynamically adjusts the size of matrix cells to fit the chart area. + * + * This plugin calculates the optimal width and height for matrix cells based on the chart's available + * area and the maximum x and y values in the dataset. It ensures that cells scale proportionally + * with the chart's dimensions, preventing overlaps and maintaining a visually appealing layout. + * + * The plugin operates during the `afterLayout` lifecycle hook, which guarantees that the chart area + * has been properly calculated before cell size adjustments are made. + */ +export const matrixAutoScaleCellSize: Plugin<"matrix"> = { + id: "matrix-cell-size", + afterLayout: (chart) => { + const chartArea = chart.chartArea; + + for (const dataset of chart.data.datasets) { + const maxX = Math.max(...dataset.data.map((point) => point.x)); + const maxY = Math.max(...dataset.data.map((point) => point.y)); + + const xLength = maxX + 1; + const yLength = maxY + 1; + + const cellWidth = chartArea.width / xLength; + const cellHeight = chartArea.height / yLength; + + // -1 stops overlaps + dataset.width = () => cellWidth - 1; + dataset.height = () => cellHeight - 1; + } + }, +}; From 665d10e34e094dcc08f93ddc542824b64b3a0f93 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 17:45:48 +0200 Subject: [PATCH 7/8] remove jsdocs extras --- src/charts/plugins/matrix.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/charts/plugins/matrix.ts b/src/charts/plugins/matrix.ts index 66ffed5..c9a607a 100644 --- a/src/charts/plugins/matrix.ts +++ b/src/charts/plugins/matrix.ts @@ -1,8 +1,7 @@ import { Plugin } from "chart.js"; /** - * @name matrixAutoScaleCellSize - * @description A Chart.js plugin that dynamically adjusts the size of matrix cells to fit the chart area. + * A Chart.js plugin that dynamically adjusts the size of matrix cells to fit the chart area. * * This plugin calculates the optimal width and height for matrix cells based on the chart's available * area and the maximum x and y values in the dataset. It ensures that cells scale proportionally From 49ef66ec1ac2877b26c72a2ca9403600f001dab4 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Fri, 18 Apr 2025 17:50:09 +0200 Subject: [PATCH 8/8] comments --- src/charts/canvas.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/charts/canvas.ts b/src/charts/canvas.ts index f6d013e..c061316 100644 --- a/src/charts/canvas.ts +++ b/src/charts/canvas.ts @@ -2,12 +2,12 @@ import { Chart, ChartConfiguration } from "chart.js"; import { MatrixController, MatrixElement } from "chartjs-chart-matrix"; import { ChartJSNodeCanvas, MimeType } from "chartjs-node-canvas"; -// init +// Chart.js initialization. Chart.register(MatrixController, MatrixElement); // NOTE: -// we want to reuse canvases because of memory management -// if we want different sizes we need to create multiple canvases +// We reuse the same canvas instance for memory efficiency. +// If different chart sizes are needed, create multiple canvas instances. const canvasConfiguration = { width: 800,