diff --git a/README.md b/README.md index 2f77762..69d6c91 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,5 @@ npm run lint 1. From the same packet, download `who_sub_regions.csv` (under 'Other files'). This provides the mapping from countries to subregions. 1. Delete contents of `public/data/csv`. 1. Unzip the dataviz.zip folder into `public/data/csv`, and move `who_sub_regions.csv` there too. +1. Ensure your current Node version is >= 24. 1. Run `./scripts/convert-csv-files-to-json.sh ` replacing the packet id argument diff --git a/package-lock.json b/package-lock.json index 71aea20..d33937c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "vaxviz", "version": "0.0.0", "dependencies": { + "@reside-ic/skadi-chart": "^1.1.3", "flowbite": "^4.0.1", "flowbite-vue": "^0.2.2", "perfect-debounce": "^2.0.0", @@ -38,6 +39,7 @@ "msw": "^2.12.3", "npm-run-all2": "^8.0.4", "prettier": "3.6.2", + "sass-embedded": "^1.95.1", "tailwindcss": "^4.1.17", "typescript": "~5.9.0", "vite": "^7.1.11", @@ -127,6 +129,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -601,6 +604,13 @@ "node": ">=18" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz", + "integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==", + "devOptional": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -689,6 +699,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -735,6 +746,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1756,6 +1768,315 @@ "dev": true, "license": "MIT" }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1813,6 +2134,21 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@reside-ic/skadi-chart": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@reside-ic/skadi-chart/-/skadi-chart-1.1.3.tgz", + "integrity": "sha512-CRv1X4LqMwe1+EYnkqSzl5w2HZsyxiusjGbZDQtBjId678QuYlC+WbCFfjE8mPMj6TQYUlWRW52bgYPpCNzUWA==", + "dependencies": { + "d3-axis": "^3.0.0", + "d3-brush": "^3.0.0", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-transition": "^3.0.1", + "html2canvas": "^1.4.1", + "vue": "^3.5.13" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.29", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", @@ -2573,6 +2909,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -3493,6 +3830,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3636,6 +3974,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.20", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", @@ -3686,7 +4033,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3715,6 +4062,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -3729,6 +4077,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-builder": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", + "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", + "devOptional": true, + "license": "MIT/X11" + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -3803,6 +4158,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "optional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -3917,6 +4288,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -3996,6 +4374,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -4060,6 +4447,203 @@ "node": ">=8.0.0" } }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -4336,6 +4920,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4396,6 +4981,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4475,6 +5061,7 @@ "integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -4746,7 +5333,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5048,7 +5635,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5099,6 +5686,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5150,6 +5750,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5184,6 +5791,15 @@ "dev": true, "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -5219,7 +5835,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5239,7 +5855,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5284,7 +5900,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5976,7 +6592,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -6147,6 +6763,13 @@ "dev": true, "license": "MIT" }, + "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", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.26", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", @@ -6486,7 +7109,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6580,6 +7203,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6628,6 +7252,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6703,6 +7328,20 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6782,6 +7421,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6862,6 +7502,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -6869,6 +7519,374 @@ "dev": true, "license": "MIT" }, + "node_modules/sass": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.95.1.tgz", + "integrity": "sha512-uPoDh5NIEZV4Dp5GBodkmNY9tSQfXY02pmCcUo+FR1P+x953HGkpw+vV28D4IqYB6f8webZtwoSaZaiPtpTeMg==", + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-embedded": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.95.1.tgz", + "integrity": "sha512-l086+s40Z0qP7ckj4T+rI/7tZcwAfcKCG9ah9A808yINWOxZFv0kO0u/UHhR4G9Aimeyax/JNvqh8RE7z1wngg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.5.0", + "buffer-builder": "^0.2.0", + "colorjs.io": "^0.5.0", + "immutable": "^5.0.2", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.95.1", + "sass-embedded-android-arm": "1.95.1", + "sass-embedded-android-arm64": "1.95.1", + "sass-embedded-android-riscv64": "1.95.1", + "sass-embedded-android-x64": "1.95.1", + "sass-embedded-darwin-arm64": "1.95.1", + "sass-embedded-darwin-x64": "1.95.1", + "sass-embedded-linux-arm": "1.95.1", + "sass-embedded-linux-arm64": "1.95.1", + "sass-embedded-linux-musl-arm": "1.95.1", + "sass-embedded-linux-musl-arm64": "1.95.1", + "sass-embedded-linux-musl-riscv64": "1.95.1", + "sass-embedded-linux-musl-x64": "1.95.1", + "sass-embedded-linux-riscv64": "1.95.1", + "sass-embedded-linux-x64": "1.95.1", + "sass-embedded-unknown-all": "1.95.1", + "sass-embedded-win32-arm64": "1.95.1", + "sass-embedded-win32-x64": "1.95.1" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.95.1.tgz", + "integrity": "sha512-ObGM3xSHEK2fu89GusvAdk1hId3D1R03CyQ6/AVTFSrcBFav1a3aWUmBWtImzf5LsVzliRnlAPPS6+rT/Ghb1A==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.95.1" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.95.1.tgz", + "integrity": "sha512-siaN1TVEjhBP4QJ5UlDBRhyKmMbFhbdcyHj0B4hIuNcinuVprP6tH1NT0NkHvkXh2egBmTvjzZgJ1ySsCB32JA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.95.1.tgz", + "integrity": "sha512-E+3vZXhUOVHFiSITH2g53/ynxTG4zz8vTVrXGAKkZQwSe6aCO22uc1Pah23F3jOrDNF/YLrsyp82T/CIIczK3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.95.1.tgz", + "integrity": "sha512-UcPcr5JXVtInD+/XE+2DhwPsALUdRAHyippnnAP6MtdaT3+AnqqvzSVy9Gb6SKyeqEk4YxPmIlQpZCVODDT4eA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.95.1.tgz", + "integrity": "sha512-sW/TO+B0Wq9VDTa7YiO74DW4iF9jEYds+9yslaHtc69r/Ch+Zj+ZB6HeJysfmen91zn5CLJDGrnTSrIk+/COfQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.95.1.tgz", + "integrity": "sha512-SWTCwszlBzjin35T2OiGZSDRbC/sqg5Mjepih18lelELrz14eB9LcFTZeiqDfdnwx6qQqPWj2VufCpExr8jElA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.95.1.tgz", + "integrity": "sha512-0GZEgkE1e8E2h97lUtwgZbKHrJYmRE/KhWQBHv6ZueAto8DJcAFNFrIQiQoRJjraE6QTaw6ahSvc1YJ7gL4OQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.95.1.tgz", + "integrity": "sha512-zUAm/rztm5Uyy+DSs408VJg404siVgUuZyqId4tFwkPNC5WRKu25Z8bFMriyGaE4YfEqbNwFV07C16mJoGeVOA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.95.1.tgz", + "integrity": "sha512-MQxa+qVX7Os2rMpJ/AvhWup+1cS0JieQgCfi9cz1Zckn4zaUhg35+m2FQhfKvzv4afeW5bubTMOQeTRMQujbXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.95.1.tgz", + "integrity": "sha512-gNdaGmM3nZ0jkFNmyXWyNlXZPdaMP+7n5Mk3yGFGShqRt/6T/bHh5SkyNnU2ZdP1z7R9poPItJhULrZJ42ETeA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.95.1.tgz", + "integrity": "sha512-8lD5vHGzBjBRCMIr9CXCyjmy8Q1q+H4ygcYCIm/aPNYhrm9uPOzJfs8hv9kDRgRAASFkcPGlFw8tDH4QqiJ5wg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.95.1.tgz", + "integrity": "sha512-WjKfHxnFc/jOL5QtmgYuiWCc4616V15DkpE+7z41JWEawRXku6w++w7AR+Zx/jbz93FZ/AsZp27IS3XUt80u3Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.95.1.tgz", + "integrity": "sha512-3U6994SRUUmC8mPvSG/vNLUo2ZcGv3jHuPoBywTbJhGQI8gq0hef1MY8TU5mvtj9DhQYlah6MYktM4YrOQgqcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.95.1.tgz", + "integrity": "sha512-CJ0tEEQnfpJEMCQrdubLsmuVc/c66EgaCAO0ZgSJ/KpxBKF3O1lHN6e1UErRf6VO0rh8ExAOh75po12Vu849Og==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.95.1.tgz", + "integrity": "sha512-nGnzrEpZZOsGOwrRVyX4t15M8ijZWhc4e4lLpOqaPm+lv23HFncfY05WxU5bRj0KAknrkeTM2IX/6veP2aeUdA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.95.1.tgz", + "integrity": "sha512-bhywAcadVQoCotD4gVmyMBi2SENPvyLFPrXf33VK5mY487Nf/g5SgGCUuGmfTsbns4NBwbwR7PA/1fnJmeMtdA==", + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.95.1" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.95.1.tgz", + "integrity": "sha512-RWWODCthWdMVODoq98lyIk9R56mgGJ4TFUjD9LSCe7fAYD/tiTkUabE4AUzkZqknQSYr0n0Q2uy7POSDIKvhVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.95.1.tgz", + "integrity": "sha512-jotHgOQnCb1XdjK0fhsyuhsfox7Y5EkrOc4h2caEpRcNCnsPTBZHqhuc8Lnw8HbKIhwKYkqWhexkjgz62MShhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7176,6 +8194,29 @@ "dev": true, "license": "MIT" }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "sync-message-port": "^1.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/sync-message-port": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", + "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -7234,6 +8275,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7286,6 +8336,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7327,7 +8378,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7385,6 +8436,13 @@ "typescript": ">=4.8.4" } }, + "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==", + "devOptional": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7417,6 +8475,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7544,11 +8603,28 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.1.12", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7784,6 +8860,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7797,6 +8874,7 @@ "integrity": "sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.14", "@vitest/mocker": "4.0.14", @@ -7894,6 +8972,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.22", "@vue/compiler-sfc": "3.5.22", diff --git a/package.json b/package.json index aceb40e..ad68f4b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format": "prettier --write src/" }, "dependencies": { + "@reside-ic/skadi-chart": "^1.1.3", "flowbite": "^4.0.1", "flowbite-vue": "^0.2.2", "perfect-debounce": "^2.0.0", @@ -49,6 +50,7 @@ "msw": "^2.12.3", "npm-run-all2": "^8.0.4", "prettier": "3.6.2", + "sass-embedded": "^1.95.1", "tailwindcss": "^4.1.17", "typescript": "~5.9.0", "vite": "^7.1.11", diff --git a/public/data/packet-id.txt b/public/data/packet-id.txt index 5205dd2..d65c444 100644 --- a/public/data/packet-id.txt +++ b/public/data/packet-id.txt @@ -1 +1 @@ -Packet 20251114-115928-130babc4O converted to json at 2025-12-08 16:27:24 +Packet 20251114-115928-130babc4O converted to json at 2025-12-17 11:02:17 diff --git a/scripts/generateOptions.ts b/scripts/generateOptions.ts index 0fd316f..9120264 100644 --- a/scripts/generateOptions.ts +++ b/scripts/generateOptions.ts @@ -3,33 +3,40 @@ import fs from "fs"; import { Dimensions, LocResolutions, type Option, type SummaryTableDataRow } from "../src/types.ts"; import { getCountryName } from "../src/utils/regions.ts"; +import titleCase from "../src/utils/titleCase.ts"; const args = process.argv.slice(2); if (args.length !== 2) { - console.error("Usage: node generate-summary-json.js <`./src/data` sub-directory>"); + console.error("Usage: node generateOptions.ts <`./src/data` sub-directory>"); process.exit(1); } const dataSourceDir = args[0]; const targetDir = args[1]; -// NB Some diseases (e.g. Malaria) are included only at the subregional level, hence we need both tables. -const summaryTableDeathsDiseaseCountryJson = dataSourceDir + '/summary_table_deaths_disease_country.json'; -const summaryTableDeathsDiseaseSubregionJson = dataSourceDir + '/summary_table_deaths_disease_subregion.json'; - -const summaryTableDeathsDiseaseCountry: SummaryTableDataRow[] = JSON.parse(fs.readFileSync(summaryTableDeathsDiseaseCountryJson, 'utf8')); -const summaryTableDeathsDiseaseSubregion: SummaryTableDataRow[] = JSON.parse(fs.readFileSync(summaryTableDeathsDiseaseSubregionJson, 'utf8')); - let countryOpts: Option[] = []; let subregionOpts: Option[] = []; let diseaseOpts: Option[] = []; +let activityTypeOpts: Option[] = []; + +// Some diseases (e.g. Malaria) are included only at the subregional or the country level, +// hence we need tables at both country and subregional levels in order to collect all disease options. +// There are also some diseases (e.g. MenA) that are only included in tables that have an activity_type breakdown, +// so we need to include those tables too. +// All four permutations are required, since at least one disease (Meningitis) is picky about both those factors. [ - summaryTableDeathsDiseaseCountry, - summaryTableDeathsDiseaseSubregion, -].map((rows: SummaryTableDataRow[]) => { + `summary_table_deaths_disease_country.json`, + `summary_table_deaths_disease_subregion.json`, + `summary_table_deaths_disease_activity_type_country.json`, + `summary_table_deaths_disease_subregion_activity_type.json` +].map((filename: string) => { + const rows: SummaryTableDataRow[] = JSON.parse( + fs.readFileSync(`${dataSourceDir}/${filename}`, 'utf8') + ); for (const row of rows) { const countryValue = row[LocResolutions.COUNTRY]?.toString(); const subregionValue = row[LocResolutions.SUBREGION]?.toString(); const diseaseValue = row[Dimensions.DISEASE].toString(); + const activityTypeValue = row[Dimensions.ACTIVITY_TYPE]?.toString(); if (countryValue && !countryOpts.find(o => o.value === countryValue)) { countryOpts.push({ value: countryValue, @@ -47,6 +54,12 @@ let diseaseOpts: Option[] = []; label: diseaseValue }); } + if (activityTypeValue && !activityTypeOpts.find(o => o.value === activityTypeValue)) { + activityTypeOpts.push({ + value: activityTypeValue, + label: titleCase(activityTypeValue) + }); + } } }); @@ -54,6 +67,7 @@ let diseaseOpts: Option[] = []; countryOpts = countryOpts.sort((a, b) => a.label.localeCompare(b.label)); subregionOpts = subregionOpts.sort((a, b) => a.label.localeCompare(b.label)); diseaseOpts = diseaseOpts.sort((a, b) => a.label.localeCompare(b.label)); +activityTypeOpts = activityTypeOpts.sort((a, b) => a.label.localeCompare(b.label)); // Helper function to write options as JSON files. function writeJson(filename: string, options: Option[]) { @@ -64,5 +78,6 @@ function writeJson(filename: string, options: Option[]) { writeJson('countryOptions.json', countryOpts); writeJson('subregionOptions.json', subregionOpts); writeJson('diseaseOptions.json', diseaseOpts); +writeJson('activityTypeOptions.json', activityTypeOpts); console.log(`Generated options JSON files in ${targetDir}`); diff --git a/src/assets/styles/main.css b/src/assets/styles/main.css index 7b046e2..efc7599 100644 --- a/src/assets/styles/main.css +++ b/src/assets/styles/main.css @@ -28,5 +28,5 @@ /* For vue3-select-component */ :root { - --vs-menu-height: 80dvh; + --vs-menu-height: 50dvh; } diff --git a/src/components/PlotControls.vue b/src/components/PlotControls.vue index 46a9a2e..132ae16 100644 --- a/src/components/PlotControls.vue +++ b/src/components/PlotControls.vue @@ -52,7 +52,7 @@ Burden metric:
{ if (appStore.exploreBy === Dimensions.LOCATION) { return [{ label: "Global", - options: [ - { label: `All ${countryOptions.length} VIMC countries`, value: LocResolutions.GLOBAL as string } - ] + options: [globalOption] }, { label: "Subregions", options: subregionOptions diff --git a/src/components/RidgelinePlot.vue b/src/components/RidgelinePlot.vue index 251e6ca..2018084 100644 --- a/src/components/RidgelinePlot.vue +++ b/src/components/RidgelinePlot.vue @@ -1,35 +1,171 @@ - diff --git a/src/composables/useHistogramLines.ts b/src/composables/useHistogramLines.ts new file mode 100644 index 0000000..ae30df1 --- /dev/null +++ b/src/composables/useHistogramLines.ts @@ -0,0 +1,100 @@ +import { Axes, Dimensions, HistCols, type Coords, type HistDataRow, type LineMetadata } from '@/types'; +import type { LineConfig, Lines } from 'types'; +import { computed, toValue } from 'vue'; + +// Construct histogram/ridge-shaped lines by building area lines whose points trace the +// outline of the histogram. +export default ( + data: () => HistDataRow[], + axisDimensions: () => { + [Axes.COLUMN]: Dimensions | null; + [Axes.ROW]: Dimensions; + [Axes.WITHIN_BAND]: Dimensions; + }, + getCategory: (dim: Dimensions | null, dataRow: HistDataRow) => string, + getLabel: (dim: Dimensions | null, value: string) => string | undefined, +) => { + const dimensions = toValue(axisDimensions); + + // Return corner coordinates of the histogram bar representing a row from a data file. + const createBarCoords = (dataRow: HistDataRow): Coords[] => { + const topLeftPoint = { x: dataRow[HistCols.LOWER_BOUND], y: dataRow[HistCols.COUNTS] }; + const topRightPoint = { x: dataRow[HistCols.UPPER_BOUND], y: dataRow[HistCols.COUNTS] }; + // closingOffPoint is a point at y=0 to close off the histogram bar, which will be needed if either: + // 1) this ends up being the last bar in the line, or + // 2) there is a data gap until the next bar in this line. + // Since we don't know if (1) or (2) hold at this point (we haven't processed the next bar yet), + // we always add the closingOffPoint, and remove it later if unneeded. + const closingOffPoint = { x: dataRow[HistCols.UPPER_BOUND], y: 0 }; + return [topLeftPoint, topRightPoint, closingOffPoint]; + } + + // Initialize a skadi-chart LineConfig object to be used to draw a 'ridgeline' (the outline of a histogram). + const initializeLine = ( + barCoords: Coords[], + categoryValues: LineMetadata, + ): Lines[0] => { + return { + points: barCoords, + bands: { + x: getLabel(dimensions[Axes.COLUMN], categoryValues[Axes.COLUMN]), + y: getLabel(dimensions[Axes.ROW], categoryValues[Axes.ROW]), + }, + style: {}, + metadata: categoryValues, + fill: true, + }; + }; + + // Construct histogram/ridge-shaped lines by building area lines whose points trace the + // outline of the histogram bars (including the spaces in between them). + const ridgeLines = computed((): Lines => { + // A 3-dimensional dictionary of lines. + // We use x-value as the key at the first level, then y-value on the second, then withinBandValue. + // If the x-value (or anything else) is undefined, then the key should be an empty string. + const lines: Record>>> = {}; + + toValue(data).forEach(dataRow => { + // Each line needs to know its category for each categorical axis in use. + const columnCat = getCategory(dimensions[Axes.COLUMN], dataRow); + const rowCat = getCategory(dimensions[Axes.ROW], dataRow); + const withinBandCat = getCategory(dimensions[Axes.WITHIN_BAND], dataRow); + const categoryValues = { [Axes.COLUMN]: columnCat, [Axes.ROW]: rowCat, [Axes.WITHIN_BAND]: withinBandCat }; + + const lowerBound = dataRow[HistCols.LOWER_BOUND]; + const barCoords = createBarCoords(dataRow); + + // We need to plot at most one line for each of the combinations of dimensions in use. + const line = lines[columnCat]?.[rowCat]?.[withinBandCat]; + + if (!line) { + // No line exists yet for this combination of categorical axis values, so we need to create it. + barCoords.unshift({ x: lowerBound, y: 0 }); // Start the first bar at y=0. + const newLine = initializeLine(barCoords, categoryValues); + lines[columnCat] ??= {}; + lines[columnCat]![rowCat] ??= {}; + lines[columnCat]![rowCat]![withinBandCat] = newLine; + } else { + // A line already exists for this combination of categorical axis values, so we can append some points to it. + const previousPoint = line.points[line.points.length - 1]; + // If you encounter overlapping histogram bars, you can use >= instead of === in the below condition, + // though this is not expected. + if (previousPoint && previousPoint.x === lowerBound && previousPoint.y === 0) { + // If the previous bar's upper bound is the same as this bar's lower bound, + // we should remove the previous close-off point, as we are continuing the line directly from the previous bar to this bar. + line.points.pop(); + } else if (previousPoint) { + // There is a previous section, complete with close-off point, but this bar is disconnected from it. + // Leave the previous close-off point and make a new starting point at y=0. + line.points.push({ x: lowerBound, y: 0 }); + } + line.points.push(...barCoords); + } + }); + + // Unpack the lines dictionary into a flat array. + return Object.values(lines).flatMap(y => Object.values(y)).flatMap(z => Object.values(z)); + }); + + return { ridgeLines } +} diff --git a/src/data/options/activityTypeOptions.json b/src/data/options/activityTypeOptions.json new file mode 100644 index 0000000..a407bb8 --- /dev/null +++ b/src/data/options/activityTypeOptions.json @@ -0,0 +1,10 @@ +[ + { + "value": "campaign", + "label": "Campaign" + }, + { + "value": "routine", + "label": "Routine" + } +] \ No newline at end of file diff --git a/src/data/options/diseaseOptions.json b/src/data/options/diseaseOptions.json index c1c1800..f85079c 100644 --- a/src/data/options/diseaseOptions.json +++ b/src/data/options/diseaseOptions.json @@ -31,6 +31,14 @@ "value": "Measles", "label": "Measles" }, + { + "value": "MenA", + "label": "MenA" + }, + { + "value": "MenACWYX", + "label": "MenACWYX" + }, { "value": "Meningitis", "label": "Meningitis" diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 20771a8..8bd1b72 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1,29 +1,23 @@ -import { BurdenMetrics, Dimensions, LocResolutions } from "@/types"; -import diseaseOptions from '@/data/options/diseaseOptions.json'; import { defineStore } from "pinia"; import { computed, ref, watch } from "vue"; - -const metricOptions = [ - { label: "DALYs averted", value: BurdenMetrics.DALYS }, - { label: "Deaths averted", value: BurdenMetrics.DEATHS }, -]; - -const exploreOptions = [ - { label: "Disease", value: Dimensions.DISEASE }, - { label: "Geography", value: Dimensions.LOCATION }, -]; +import { getSubregionFromCountry } from "@/utils/regions" +import { Axes, BurdenMetrics, Dimensions, LocResolutions } from "@/types"; +import countryOptions from '@/data/options/countryOptions.json'; +import subregionOptions from '@/data/options/subregionOptions.json'; +import diseaseOptions from '@/data/options/diseaseOptions.json'; +import { exploreOptions, globalOption } from "@/utils/options"; export const useAppStore = defineStore("app", () => { const burdenMetric = ref(BurdenMetrics.DEATHS); const logScaleEnabled = ref(true); const splitByActivityType = ref(false); - // The x categorical axis corresponds to horizontal slicing of the ridgeline plot (columns). - const xCategoricalAxis = ref(splitByActivityType.value ? Dimensions.ACTIVITY_TYPE : null); - // The y categorical axis corresponds to the rows of the ridgeline plot. - const yCategoricalAxis = ref(Dimensions.DISEASE); + // The column axis corresponds to horizontal splitting of the ridgeline plot, known internally to skadi-chart as the 'x categorical' axis. + // The row axis corresponds to the rows of the ridgeline plot, known internally to skadi-chart as the 'y categorical' axis. // The 'within-band' axis is often denoted by color. It distinguishes different lines that share the same categorical axis values. - const withinBandAxis = ref(Dimensions.LOCATION); + const columnDimension = ref(splitByActivityType.value ? Dimensions.ACTIVITY_TYPE : null); + const rowDimension = ref(Dimensions.DISEASE); + const withinBandDimension = ref(Dimensions.LOCATION); // The plot presents a slice of the data depending on the user's choice of a 'focus' value that is either // a specific location or a specific disease of interest. @@ -32,18 +26,53 @@ export const useAppStore = defineStore("app", () => { const exploreBy = ref(Dimensions.LOCATION); const focus = ref(LocResolutions.GLOBAL); + const filters = ref>({ + [Dimensions.DISEASE]: diseaseOptions.map(d => d.value), + [Dimensions.LOCATION]: [LocResolutions.GLOBAL], + }); + const exploreByLabel = computed(() => { const option = exploreOptions.find(o => o.value === exploreBy.value); return option ? option.label : ""; }); - // The dimensions currently in use: up to three will be in use at any given time. + // The dimensions currently in use, by axis: up to three will be in use at any given time. const dimensions = computed(() => ({ - x: xCategoricalAxis.value, - y: yCategoricalAxis.value, - withinBand: withinBandAxis.value + [Axes.COLUMN]: columnDimension.value, + [Axes.ROW]: rowDimension.value, + [Axes.WITHIN_BAND]: withinBandDimension.value })); + // The geographical resolutions to use based on current exploreBy and focus selections. + const geographicalResolutions = computed(() => { + if (exploreBy.value === Dimensions.DISEASE) { + return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; + } else { + if (focus.value === LocResolutions.GLOBAL) { + return [LocResolutions.GLOBAL]; + } else if (subregionOptions.find(o => o.value === focus.value)) { + return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; + } else if (countryOptions.find(o => o.value === focus.value)) { + return [LocResolutions.COUNTRY, LocResolutions.SUBREGION, LocResolutions.GLOBAL]; + } + // The following line should never be able to be evaluated, because exploreBy is always either + // 'disease' or 'location', and the three possible types of location are covered by the branches. + throw new Error(`Invalid focus selection '${focus.value}' for exploreBy '${exploreBy.value}'`); + } + }); + + const getLocationForGeographicalResolution = (geog: LocResolutions) => { + switch (geog) { + case LocResolutions.GLOBAL: + return globalOption.value; + case LocResolutions.SUBREGION: + return subregionOptions.find(o => o.value === focus.value)?.value + ?? getSubregionFromCountry(focus.value); + case LocResolutions.COUNTRY: + return focus.value; + } + } + watch(exploreBy, () => { if (exploreBy.value === Dimensions.DISEASE && diseaseOptions[0]) { focus.value = diseaseOptions[0].value; @@ -55,19 +84,29 @@ export const useAppStore = defineStore("app", () => { watch(focus, () => { const focusIsADisease = diseaseOptions.find(d => d.value === focus.value); if (focusIsADisease) { - yCategoricalAxis.value = Dimensions.LOCATION; - withinBandAxis.value = Dimensions.DISEASE; + rowDimension.value = Dimensions.LOCATION; + withinBandDimension.value = Dimensions.DISEASE; + + filters.value = { + [Dimensions.DISEASE]: [focus.value], + [Dimensions.LOCATION]: subregionOptions.map(o => o.value).concat([LocResolutions.GLOBAL]), + }; } else { // This is only one possible way of 'focusing' on a 'location': - // diseases as categorical Y axis, each row with up to 3 ridges. - // An alternative would be to have the 3 location rows laid out on the categorical Y axis, + // diseases as row axis, each row with up to 3 ridges. + // An alternative would be to have the 3 location rows laid out on the row axis, // and disease(s) as color axis. - yCategoricalAxis.value = Dimensions.DISEASE; - withinBandAxis.value = Dimensions.LOCATION; + rowDimension.value = Dimensions.DISEASE; + withinBandDimension.value = Dimensions.LOCATION; + + filters.value = { + [Dimensions.DISEASE]: diseaseOptions.map(d => d.value), + [Dimensions.LOCATION]: geographicalResolutions.value.map(getLocationForGeographicalResolution), + }; }; }); - watch(splitByActivityType, (split) => xCategoricalAxis.value = split ? Dimensions.ACTIVITY_TYPE : null); + watch(splitByActivityType, (split) => columnDimension.value = split ? Dimensions.ACTIVITY_TYPE : null); return { burdenMetric, @@ -75,9 +114,10 @@ export const useAppStore = defineStore("app", () => { exploreBy, exploreByLabel, exploreOptions, + filters, focus, + geographicalResolutions, logScaleEnabled, - metricOptions, splitByActivityType, }; }) diff --git a/src/stores/colorStore.ts b/src/stores/colorStore.ts new file mode 100644 index 0000000..747f47d --- /dev/null +++ b/src/stores/colorStore.ts @@ -0,0 +1,108 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { Axes, Dimensions, type LineMetadata } from "@/types"; +import { useAppStore } from "@/stores/appStore"; +import { globalOption } from "@/utils/options"; + +// The IBM categorical palettes, which aim to maximise accessibility: +// https://carbondesignsystem.com/data-visualization/color-palettes/#categorical-palettes + +// `ibmAccessiblePalette`: This sequence is to be used when the number of categories is unknown in advance, or > 5. +// It should be used specifically in this ordering. +const ibmAccessiblePalette = Object.freeze({ + purple70: "#6929c4", + cyan50: "#1192e8", + teal70: "#005d5d", + magenta70: "#9f1853", + red50: "#fa4d56", + red90: "#570408", + green60: "#198038", + blue80: "#002d9c", + magenta50: "#ee538b", + yellow50: "#b28600", + teal50: "#009d9a", + cyan90: "#012749", + orange70: "#8a3800", + purple50: "#a56eff", +}); + +// Not from IBM. We need to have as many color options as there are diseases. +const extraColors = { + black: "#000000", + white: "#ffffff", +}; + +// Certain specific palettes are to be used when the number of categories is known in advance (1 to 5): +// https://carbondesignsystem.com/data-visualization/color-palettes/#categorical-palettes +// The following palettes were selected from the palette options so as to ensure there is always a purple70 in the mix. +const palettesByCategoryCount: Record = Object.freeze( + Object.entries({ + 2: ["purple70", "teal50"], // IBM 2-color group option 1 + 3: ["purple70", "cyan50", "magenta50"], // IBM 3-color group option 4 (in reverse order to put purple70 first) + 4: ["purple70", "cyan90", "teal50", "magenta50"], // IBM 4-color group option 2 + 5: ["purple70", "cyan50", "teal70", "magenta70", "red90"], // IBM 5-color group option 1 + }).reduce((acc, [key, colorKeys]) => { + // Convert friendly-names to hex codes + acc[Number(key)] = colorKeys.map(colorKey => ibmAccessiblePalette[colorKey as keyof typeof ibmAccessiblePalette]); + return acc; + }, {} as Record) +); + +export const useColorStore = defineStore("color", () => { + const appStore = useAppStore(); + + // colorDimension is the dimension (i.e. 'location' or 'disease') + // whose values determine the colors for the lines. + const colorDimension = computed(() => { + // If there are multiple filtered values on the withinBand axis, those values determine the colors. + + // If we're filtered to just 1 value for the withinBand axis, + // we assign colors based on the dimension assigned to the y-axis, + // otherwise all lines would be the same color across all rows. + return appStore.filters[appStore.dimensions[Axes.WITHIN_BAND]]?.length === 1 + ? appStore.dimensions[Axes.ROW] + : appStore.dimensions[Axes.WITHIN_BAND]; + }); + + // The mapping from category value (e.g. a specific location or disease) to color hex code. + // By setting the global color first, we ensure that it gets the same color across chart updates. + const mapping = ref( + colorDimension.value === Dimensions.LOCATION + ? new Map([[globalOption.value, ibmAccessiblePalette.purple70]]) + : new Map() + ); + // Expose a read-only version of the mapping to consumers of the store. + const colorMapping = computed(() => mapping.value as ReadonlyMap); + + const colorList = computed(() => { + const categories = appStore.filters[colorDimension.value] ?? []; + return palettesByCategoryCount[categories.length] ?? Object.values({ ...ibmAccessiblePalette, ...extraColors }) + }); + + // Given a line's category values, either fetch the color from the mapping, + // or assign it the next color in the list and return that. + const getColorsForLine = (categoryValues: LineMetadata) => { + const colorAxis = Object.keys(appStore.dimensions).find((axis) => { + return appStore.dimensions[axis as Axes] === colorDimension.value; + }) as Axes; + // `value` is the specific value, i.e. a specific location or disease, + // whose color we need to look up or assign. + const value = categoryValues[colorAxis]; + const fillColor = mapping.value?.get(value) ?? colorList.value[mapping.value.size]; + if (fillColor) { + mapping.value.set(value, fillColor); + } + return { + fillColor: fillColor, + strokeColor: fillColor === extraColors.white ? extraColors.black : fillColor, + }; + }; + + const resetColorMapping = () => { + mapping.value = colorDimension.value === Dimensions.LOCATION + ? new Map([[globalOption.value, ibmAccessiblePalette.purple70]]) + : new Map(); + } + + return { colorDimension, colorMapping, getColorsForLine, resetColorMapping }; +}); diff --git a/src/stores/dataStore.ts b/src/stores/dataStore.ts index 360c7e9..d9aca54 100644 --- a/src/stores/dataStore.ts +++ b/src/stores/dataStore.ts @@ -1,11 +1,8 @@ -import { useAppStore } from "@/stores/appStore"; -import { type DataRow, Dimensions, LocResolutions } from "@/types"; import { debounce } from "perfect-debounce"; import { computed, ref, shallowRef, watch } from "vue"; - -import countryOptions from '@/data/options/countryOptions.json'; -import subregionOptions from '@/data/options/subregionOptions.json'; import { defineStore } from "pinia"; +import { useAppStore } from "@/stores/appStore"; +import { type HistDataRow, Dimensions, LocResolutions } from "@/types"; export const dataDir = `/data/json` @@ -13,31 +10,12 @@ export const useDataStore = defineStore("data", () => { const appStore = useAppStore(); const fetchErrors = ref<{ e: Error, message: string }[]>([]); - const histogramData = shallowRef([]); - const histogramDataCache: Record = {}; - - // The geographical resolutions to use based on current exploreBy and focus selections. - // This is currently exposed by the composable but that's only for manual testing purposes. - const geographicalResolutions = computed(() => { - if (appStore.exploreBy === Dimensions.DISEASE) { - return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; - } else { - if (appStore.focus === LocResolutions.GLOBAL) { - return [LocResolutions.GLOBAL]; - } else if (subregionOptions.find(o => o.value === appStore.focus)) { - return [LocResolutions.SUBREGION, LocResolutions.GLOBAL]; - } else if (countryOptions.find(o => o.value === appStore.focus)) { - return [LocResolutions.COUNTRY, LocResolutions.SUBREGION, LocResolutions.GLOBAL]; - } - // The following line should never be able to be evaluated, because exploreBy is always either - // 'disease' or 'location', and the three possible types of location are covered by the branches. - throw new Error(`Invalid focus selection '${appStore.focus}' for exploreBy '${appStore.exploreBy}'`); - } - }); + const histogramData = shallowRef([]); + const histogramDataCache: Record = {}; const histogramDataPaths = computed(() => { // When we are using multiple geographical resolutions, we need to load multiple data files, to be merged together later. - return geographicalResolutions.value.map((geog) => { + return appStore.geographicalResolutions.map((geog) => { const fileNameParts = ["hist_counts", appStore.burdenMetric, "disease"]; // NB files containing 'global' data simply omit location from the file name (as they have no location stratification). if (geog === LocResolutions.SUBREGION) { @@ -75,7 +53,17 @@ export const useDataStore = defineStore("data", () => { } })); - histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []); + histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []).map((row) => { + // Collapse all geographic columns into one 'location' column + if (row[LocResolutions.COUNTRY]) { + row[Dimensions.LOCATION] = row[LocResolutions.COUNTRY]; + delete row[LocResolutions.COUNTRY]; + } else if (row[LocResolutions.SUBREGION]) { + row[Dimensions.LOCATION] = row[LocResolutions.SUBREGION]; + delete row[LocResolutions.SUBREGION]; + } + return row; + }); }; const doLoadData = debounce(async () => { @@ -91,5 +79,5 @@ export const useDataStore = defineStore("data", () => { } }, { immediate: true }); - return { histogramData, fetchErrors, geographicalResolutions }; + return { histogramData, fetchErrors }; }); diff --git a/src/types.ts b/src/types.ts index cfd3740..8aa1ec7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,12 +16,39 @@ export enum LocResolutions { COUNTRY = "country", } -export type DataRow = Record; +export enum HistCols { + LOWER_BOUND = "lower_bound", + UPPER_BOUND = "upper_bound", + COUNTS = "Counts", +} + +type DataRow = Record; export type SummaryTableDataRow = DataRow & { [Dimensions.DISEASE]: string; [LocResolutions.COUNTRY]?: string; [LocResolutions.SUBREGION]?: string; }; +export type HistDataRow = DataRow & { + [Dimensions.DISEASE]: string; + [LocResolutions.COUNTRY]?: string; + [LocResolutions.SUBREGION]?: string; + [Dimensions.LOCATION]?: string; + [HistCols.LOWER_BOUND]: number; + [HistCols.UPPER_BOUND]: number; + [HistCols.COUNTS]: number; +}; export type Option = { label: string; value: string }; +export type Coords = { x: number; y: number }; + +// The column axis corresponds to horizontal splitting of the ridgeline plot, known internally to skadi-chart as the 'x categorical' axis. +// The row axis corresponds to the rows of the ridgeline plot, known internally to skadi-chart as the 'y categorical' axis. +// The 'within-band' axis is often denoted by color. It distinguishes different lines that share the same categorical axis values. +export enum Axes { + COLUMN = "column", + ROW = "row", + WITHIN_BAND = "withinBand", +} + +export type LineMetadata = Record; diff --git a/src/utils/fileParse.ts b/src/utils/fileParse.ts new file mode 100644 index 0000000..a49a10b --- /dev/null +++ b/src/utils/fileParse.ts @@ -0,0 +1,13 @@ +import { Dimensions, type HistDataRow } from "../types"; +import { globalOption } from "./options"; + +// Get a data row's category for some categorical axis. +export const getDimensionCategoryValue = (dim: Dimensions | null, dataRow: HistDataRow): string => { + const value = dataRow[dim ?? ""] as string; + if (dim === Dimensions.LOCATION && !value) { + // A missing column for the location dimension implies 'global' category. + return globalOption.value; + } else { + return value; + } +}; diff --git a/src/utils/options.ts b/src/utils/options.ts new file mode 100644 index 0000000..0f9b711 --- /dev/null +++ b/src/utils/options.ts @@ -0,0 +1,36 @@ +// This file contains hard-coded options. +// Static, automatically-generated options go in src/data/options/. +import { BurdenMetrics, Dimensions, LocResolutions } from "@/types"; +import countryOptions from '@/data/options/countryOptions.json'; +import diseaseOptions from '@/data/options/diseaseOptions.json'; +import subregionOptions from '@/data/options/subregionOptions.json'; +import activityTypeOptions from '@/data/options/activityTypeOptions.json'; + +export const metricOptions = [ + { label: "DALYs averted", value: BurdenMetrics.DALYS }, + { label: "Deaths averted", value: BurdenMetrics.DEATHS }, +]; + +export const exploreOptions = [ + { label: "Disease", value: Dimensions.DISEASE }, + { label: "Geography", value: Dimensions.LOCATION }, +]; + +export const globalOption = { + label: `All ${countryOptions.length} VIMC countries`, + value: LocResolutions.GLOBAL as string +}; + +const locationOptions = countryOptions.concat(subregionOptions).concat([globalOption]); + +// Get a data category's human-readable label from its value and dimension. +export const dimensionOptionLabel = (dim: Dimensions | null, value: string): string | undefined => { + if (!value || !dim) { + return; + } + return { + [Dimensions.LOCATION]: locationOptions, + [Dimensions.DISEASE]: diseaseOptions, + [Dimensions.ACTIVITY_TYPE]: activityTypeOptions, + }[dim]?.find(o => o.value === value)?.label ?? value +}; diff --git a/src/utils/titleCase.ts b/src/utils/titleCase.ts new file mode 100644 index 0000000..3ad6456 --- /dev/null +++ b/src/utils/titleCase.ts @@ -0,0 +1,3 @@ +export default (str?: string) => str?.split(' ').map((word) => { + return word.charAt(0).toUpperCase() + word?.toLowerCase().slice(1) +}).join(' '); diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 7832ca5..a9dde0f 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -46,23 +46,25 @@ test('visits the app root url, selects options, and loads correct data', async ( await expect(logScaleCheckbox).toBeChecked(); await expect(dalysRadio).not.toBeChecked(); await expect(deathsRadio).toBeChecked(); - - const dataAttr0 = await chartWrapper.getAttribute("data-test"); - const data0 = JSON.parse(dataAttr0!); - expect(data0.histogramDataRowCount).toEqual(histCountsDeathsDiseaseLog.length); - expect(data0.x).toBeNull(); - expect(data0.y).toBe("disease"); - expect(data0.withinBand).toBe("location"); + await expect(chartWrapper).toHaveAttribute("data-test", + JSON.stringify({ + histogramDataRowCount: histCountsDeathsDiseaseLog.length, + lineCount: 14, // 14 diseases have global data for aggregated activity type. + column: null, + row: "disease", + withinBand: "location", + }) + ); // Change options: round 1 - await selectFocus(page, "location", "Central and Southern Asia"); + await selectFocus(page, "location", "Middle Africa"); await dalysRadio.click(); await logScaleCheckbox.click(); await activityTypeCheckbox.click(); await expect(diseaseRadio).not.toBeChecked(); await expect(geographyRadio).toBeChecked(); - await expectSelectedFocus(page, "location", "Central and Southern Asia"); + await expectSelectedFocus(page, "location", "Middle Africa"); await expect(activityTypeCheckbox).toBeChecked(); await expect(logScaleCheckbox).not.toBeChecked(); await expect(dalysRadio).toBeChecked(); @@ -71,8 +73,9 @@ test('visits the app root url, selects options, and loads correct data', async ( JSON.stringify({ histogramDataRowCount: histCountsDalysDiseaseSubregionActivityType.length + histCountsDalysDiseaseActivityType.length, - x: "activity_type", - y: "disease", + lineCount: 44, // Not all diseases have data for all subregions and activity types. + column: "activity_type", + row: "disease", withinBand: "location", }) ); @@ -94,8 +97,9 @@ test('visits the app root url, selects options, and loads correct data', async ( JSON.stringify({ histogramDataRowCount: histCountsDeathsDiseaseSubregionActivityType.length + histCountsDeathsDiseaseActivityType.length, - x: "activity_type", - y: "location", + lineCount: 22, // 10 applicable subregions with measles, + global, each with 2 activity types + column: "activity_type", + row: "location", withinBand: "disease", }) ); @@ -121,8 +125,9 @@ test('visits the app root url, selects options, and loads correct data', async ( histCountsDalysDiseaseSubregionLog.length + histCountsDalysDiseaseCountryLog.length + histCountsDalysDiseaseLog.length, - x: null, - y: "disease", + lineCount: 30, // 10 applicable diseases, each with 3 locations (AFG, subregion, global) + column: null, + row: "disease", withinBand: "location", }) ); diff --git a/tests/unit/App.spec.ts b/tests/unit/App.spec.ts index 653100a..1626d26 100644 --- a/tests/unit/App.spec.ts +++ b/tests/unit/App.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' -import App from '../../src/App.vue' +import App from '@/App.vue' import { setActivePinia, createPinia } from 'pinia'; describe('App component', () => { diff --git a/tests/unit/components/PlotControls.spec.ts b/tests/unit/components/PlotControls.spec.ts index 60cf0f8..1782440 100644 --- a/tests/unit/components/PlotControls.spec.ts +++ b/tests/unit/components/PlotControls.spec.ts @@ -4,6 +4,7 @@ import { setActivePinia, createPinia } from 'pinia'; import VueSelect from "vue3-select-component"; import { nextTick } from "vue"; +import diseaseOptions from '@/data/options/diseaseOptions.json'; import PlotControls from '@/components/PlotControls.vue' describe('Controls component', () => { @@ -69,7 +70,7 @@ describe('Controls component', () => { // Expect the focus select to have updated options await vueSelect.find(".dropdown-icon").trigger("click"); const renderedOptions = vueSelect.findAll(".menu .menu-option").filter(e => e.attributes("aria-disabled") === "false"); - expect(renderedOptions.length).toBe(14); + expect(renderedOptions.length).toBe(diseaseOptions.length); expect(renderedOptions[0].text()).toBe("Cholera"); wrapper.findAll('input[name="exploreBy"]').find(e => e.element.value === "location")?.setChecked(); diff --git a/tests/unit/components/RidgelinePlot.spec.ts b/tests/unit/components/RidgelinePlot.spec.ts index 823a82e..c76277e 100644 --- a/tests/unit/components/RidgelinePlot.spec.ts +++ b/tests/unit/components/RidgelinePlot.spec.ts @@ -13,6 +13,21 @@ import histCountsDalysDiseaseLog from "@/../public/data/json/hist_counts_dalys_d import { BurdenMetrics } from '@/types'; import RidgelinePlot from '@/components/RidgelinePlot.vue' import { useAppStore } from "@/stores/appStore"; +import { useColorStore } from '@/stores/colorStore'; + +const addGridLinesSpy = vi.fn().mockReturnThis(); + +vi.mock('@reside-ic/skadi-chart', () => ({ + Chart: vi.fn().mockImplementation(class MockChart { + addAxes = vi.fn().mockReturnThis(); + addTraces = vi.fn().mockReturnThis(); + addArea = vi.fn().mockReturnThis(); + addGridLines = addGridLinesSpy; + addZoom = vi.fn().mockReturnThis(); + makeResponsive = vi.fn().mockReturnThis(); + appendTo = vi.fn(); + }), +})); describe('RidgelinePlot component', () => { beforeEach(() => { @@ -21,24 +36,30 @@ describe('RidgelinePlot component', () => { it('loads the correct data', async () => { const appStore = useAppStore(); + const colorStore = useColorStore(); const wrapper = mount(RidgelinePlot) await vi.waitFor(() => { const dataAttr = JSON.parse(wrapper.find("#chartWrapper").attributes("data-test")!); expect(dataAttr.histogramDataRowCount).toEqual(histCountsDeathsDiseaseLog.length); + expect(dataAttr.lineCount).toEqual(14); // 14 diseases have global data for aggregated activity type. // No columns - expect(dataAttr.x).toBeNull(); + expect(dataAttr.column).toBeNull(); // Rows differ by disease - expect(dataAttr.y).toEqual("disease"); + expect(dataAttr.row).toEqual("disease"); // Ridges within a band 'differ' by location // (except that in this case there is only one location in use at the moment, 'global') expect(dataAttr.withinBand).toEqual("location"); + + // Color by row; each disease has been assigned a color. + expect(colorStore.colorMapping.size).toEqual(14); + expect(addGridLinesSpy).toHaveBeenLastCalledWith({ x: true, y: false }); }); // Change options: round 1 expect(appStore.exploreBy).toEqual("location"); expect(appStore.focus).toEqual("global"); - appStore.focus = "Central and Southern Asia"; + appStore.focus = "Middle Africa"; appStore.burdenMetric = BurdenMetrics.DALYS; appStore.logScaleEnabled = false; appStore.splitByActivityType = true; @@ -47,9 +68,15 @@ describe('RidgelinePlot component', () => { expect(dataAttr.histogramDataRowCount).toEqual( histCountsDalysDiseaseSubregionActivityType.length + histCountsDalysDiseaseActivityType.length ); - expect(dataAttr.x).toEqual("activity_type"); - expect(dataAttr.y).toEqual("disease"); + // Not all diseases have data for all subregions and activity types. + expect(dataAttr.lineCount).toEqual(44); + expect(dataAttr.column).toEqual("activity_type"); + expect(dataAttr.row).toEqual("disease"); expect(dataAttr.withinBand).toEqual("location"); + + // Color by the 2 locations within each band: Middle Africa and global. + expect(colorStore.colorMapping.size).toEqual(2); + expect(addGridLinesSpy).toHaveBeenLastCalledWith({ x: false, y: false }); }); // Change options: round 2 @@ -66,9 +93,14 @@ describe('RidgelinePlot component', () => { expect(dataAttr.histogramDataRowCount).toEqual( histCountsDeathsDiseaseSubregionActivityType.length + histCountsDeathsDiseaseActivityType.length ); - expect(dataAttr.x).toEqual("activity_type"); - expect(dataAttr.y).toEqual("location"); + expect(dataAttr.lineCount).toEqual(22); // 10 applicable subregions with measles, + global, each with 2 activity types + expect(dataAttr.column).toEqual("activity_type"); + expect(dataAttr.row).toEqual("location"); expect(dataAttr.withinBand).toEqual("disease"); + + // Color by row; each location (10 subregions + global) has been assigned a color. + expect(colorStore.colorMapping.size).toEqual(11); + expect(addGridLinesSpy).toHaveBeenLastCalledWith({ x: false, y: false }); }); // Change options: round 3 @@ -85,9 +117,39 @@ describe('RidgelinePlot component', () => { expect(dataAttr.histogramDataRowCount).toEqual( histCountsDalysDiseaseSubregionLog.length + histCountsDalysDiseaseCountryLog.length + histCountsDalysDiseaseLog.length ); - expect(dataAttr.x).toBeNull(); - expect(dataAttr.y).toEqual("disease"); + expect(dataAttr.lineCount).toEqual(30); // 10 applicable diseases, each with 3 locations (AFG, subregion, global) + expect(dataAttr.column).toBeNull(); + expect(dataAttr.row).toEqual("disease"); expect(dataAttr.withinBand).toEqual("location"); + + // Color by the 3 locations within each band: AFG, Central and Southern Asia, and global. + expect(colorStore.colorMapping.size).toEqual(3); + expect(addGridLinesSpy).toHaveBeenLastCalledWith({ x: true, y: false }); }, { timeout: 2500 }); + }, 10000); + + it('when there is no data available for the selected options, shows a message instead of the chart', async () => { + const appStore = useAppStore(); + const wrapper = mount(RidgelinePlot); + + // It shows a chart initially + await vi.waitFor(() => { + const dataAttr = JSON.parse(wrapper.find("#chartWrapper").attributes("data-test")!); + expect(dataAttr.histogramDataRowCount).toEqual(histCountsDeathsDiseaseLog.length); + }); + + // Set options that lead to no data + // There is no data for MenA except if we split by activity type. + appStore.exploreBy = "disease"; + await vi.waitFor(() => { + expect(appStore.focus).toEqual("Cholera") + }); + appStore.focus = "MenA"; + appStore.splitByActivityType = false; + + await vi.waitFor(() => { + expect(wrapper.text()).toContain("No data available for the selected options."); + expect(wrapper.find("#chartWrapper").exists()).toBe(false); + }); }); }); diff --git a/tests/unit/stores/appStore.spec.ts b/tests/unit/stores/appStore.spec.ts index c556c1a..3358ebb 100644 --- a/tests/unit/stores/appStore.spec.ts +++ b/tests/unit/stores/appStore.spec.ts @@ -1,9 +1,10 @@ import { setActivePinia, createPinia } from "pinia"; import { describe, it, expect, beforeEach } from "vitest"; -import { useAppStore } from "@/stores/appStore"; import { nextTick } from "vue"; +import diseaseOptions from '@/data/options/diseaseOptions.json'; +import { useAppStore } from "@/stores/appStore"; -describe("appStore", () => { +describe("app store", () => { beforeEach(() => { setActivePinia(createPinia()); }); @@ -17,10 +18,31 @@ describe("appStore", () => { expect(store.exploreBy).toBe("location"); expect(store.focus).toBe("global"); expect(store.dimensions).toEqual({ - x: null, - y: "disease", + column: null, + row: "disease", withinBand: "location", }); + expect(store.filters).toEqual({ + disease: [ + "Cholera", + "COVID-19", + "HepB", + "Hib", + "HPV", + "JE", + "Malaria", + "Measles", + "MenA", + "MenACWYX", + "Meningitis", + "PCV", + "Rota", + "Rubella", + "Typhoid", + "YF", + ], + location: ["global"], + }); }); it("updates the focus value when exploreBy selection changes", async () => { @@ -40,31 +62,40 @@ describe("appStore", () => { expect(store.focus).toEqual("global"); }); - it("updates the y-categorical axis and within-band axis when focus changes", async () => { + it("updates the dimensions and filters when focus changes", async () => { const store = useAppStore(); expect(store.focus).toEqual("global"); expect(store.exploreBy).toEqual("location"); - expect(store.dimensions.y).toEqual("disease"); + expect(store.dimensions.row).toEqual("disease"); expect(store.dimensions.withinBand).toEqual("location"); + expect(store.filters.disease).toHaveLength(diseaseOptions.length); + expect(store.filters.location).toEqual(["global"]); store.focus = "AFG"; await nextTick(); - expect(store.dimensions.y).toEqual("disease"); + expect(store.dimensions.row).toEqual("disease"); expect(store.dimensions.withinBand).toEqual("location"); + expect(store.filters.disease).toHaveLength(diseaseOptions.length); + expect(store.filters.location).toEqual(["AFG", "Central and Southern Asia", "global"]); store.focus = "Cholera"; await nextTick(); - expect(store.dimensions.y).toEqual("location"); + expect(store.dimensions.row).toEqual("location"); expect(store.dimensions.withinBand).toEqual("disease"); + expect(store.filters.disease).toEqual(["Cholera"]); + expect(store.filters.location).toHaveLength(11); + expect(store.filters.location).toContain("global"); - store.focus = "Central and Southern Asia"; + store.focus = "Middle Africa"; await nextTick(); - expect(store.dimensions.y).toEqual("disease"); + expect(store.dimensions.row).toEqual("disease"); expect(store.dimensions.withinBand).toEqual("location"); + expect(store.filters.disease).toHaveLength(diseaseOptions.length); + expect(store.filters.location).toEqual(["Middle Africa", "global"]); }); it("returns the explore by label", async () => { diff --git a/tests/unit/stores/colorStore.spec.ts b/tests/unit/stores/colorStore.spec.ts new file mode 100644 index 0000000..202eeff --- /dev/null +++ b/tests/unit/stores/colorStore.spec.ts @@ -0,0 +1,233 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { it, expect, describe, beforeEach } from 'vitest'; + +import diseaseOptions from '@/data/options/diseaseOptions.json'; +import { globalOption } from '@/utils/options'; +import { useAppStore } from '@/stores/appStore'; +import { useColorStore } from '@/stores/colorStore'; + +const colors = { + purple70: "#6929c4", + cyan50: "#1192e8", + teal70: "#005d5d", + magenta70: "#9f1853", + red50: "#fa4d56", + red90: "#570408", + green60: "#198038", + blue80: "#002d9c", + magenta50: "#ee538b", + yellow50: "#b28600", + teal50: "#009d9a", + cyan90: "#012749", + orange70: "#8a3800", + purple50: "#a56eff", + white: "#ffffff", + black: "#000000", +}; + +describe('color store', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + describe('colorDimension', () => { + it('returns the withinBand dimension when there are multiple filtered values on that axis', () => { + const appStore = useAppStore(); + appStore.dimensions.withinBand = 'location'; + appStore.dimensions.row = 'disease'; + appStore.filters = { + location: ['AFG', 'CHN'], + disease: ['Cholera'], + }; + + const colorStore = useColorStore(); + expect(colorStore.colorDimension).toBe('location'); + }); + + it('returns the y dimension when there is a single filtered value on the withinBand axis', () => { + const appStore = useAppStore(); + appStore.dimensions.withinBand = 'location'; + appStore.dimensions.row = 'disease'; + appStore.filters = { + location: ['AFG'], + disease: ['Cholera', 'Rubella'], + }; + + const colorStore = useColorStore(); + expect(colorStore.colorDimension).toBe('disease'); + }); + }); + + describe('getColorsForLine', () => { + it("when the color dimension is location, it returns colors depending on line's location, and has the correct global option color", () => { + const appStore = useAppStore(); + appStore.dimensions.withinBand = 'location'; + appStore.dimensions.column = 'activity_type'; + appStore.dimensions.row = 'disease'; + appStore.filters = { + location: ['AFG', 'CHN', globalOption.value], + disease: ['Cholera'], + }; + + const colorStore = useColorStore(); + expect(colorStore.colorDimension).toBe('location'); + + colorStore.resetColorMapping(); + colorStore.getColorsForLine({ withinBand: 'CHN', row: 'Cholera', column: 'campaign' }); + colorStore.getColorsForLine({ withinBand: 'AFG', row: 'Cholera', column: 'campaign' }); + colorStore.getColorsForLine({ withinBand: 'AFG', row: 'Cholera', column: 'routine' }); + colorStore.getColorsForLine({ withinBand: 'global', row: 'Cholera', column: 'campaign' }); + + expect(colorStore.colorMapping.size).toBe(3); + expect(colorStore.colorMapping.get(globalOption.value)).toEqual(colors.purple70); + expect(colorStore.colorMapping.get('CHN')).toEqual(colors.cyan50); + expect(colorStore.colorMapping.get('AFG')).toEqual(colors.magenta50); + }); + + it("when the color dimension is disease, it returns colors depending on line's disease", () => { + const appStore = useAppStore(); + appStore.dimensions.withinBand = 'location'; + appStore.dimensions.column = 'activity_type'; + appStore.dimensions.row = 'disease'; + appStore.filters = { + location: ['AFG'], + disease: ['Cholera', 'Rubella'], + }; + + const colorStore = useColorStore(); + expect(colorStore.colorDimension).toBe('disease'); + colorStore.resetColorMapping(); + colorStore.getColorsForLine({ withinBand: 'AFG', row: 'Cholera', column: 'campaign' }); + colorStore.getColorsForLine({ withinBand: 'AFG', row: 'Rubella', column: 'campaign' }); + colorStore.getColorsForLine({ withinBand: 'AFG', row: 'Rubella', column: 'routine' }); + + expect(colorStore.colorMapping.size).toBe(2); + expect(colorStore.colorMapping.get('Cholera')).toEqual(colors.purple70); + expect(colorStore.colorMapping.get('Rubella')).toEqual(colors.teal50); + }); + + it('when the color has already been assigned, it returns that color, without assigning any more', () => { + const appStore = useAppStore(); + appStore.dimensions.withinBand = 'location'; + appStore.dimensions.row = 'disease'; + appStore.filters = { + location: ['AFG'], + disease: ['Cholera', 'Rubella'], + }; + + const colorStore = useColorStore(); + expect(colorStore.colorDimension).toBe('disease'); + colorStore.resetColorMapping(); + expect(colorStore.colorMapping.size).toBe(0); + colorStore.getColorsForLine({ withinBand: 'AFG', row: 'Cholera' }); + colorStore.getColorsForLine({ withinBand: 'CHN', row: 'Cholera' }); + colorStore.getColorsForLine({ withinBand: 'global', row: 'Cholera' }); + // Should return only 1 disease-color mapping + expect(colorStore.colorMapping.size).toBe(1); + expect(colorStore.colorMapping.get("Cholera")).toEqual(colors.purple70); + }); + }); + + it('chooses the color palette depending on number of categories in filter', () => { + const appStore = useAppStore(); + const colorStore = useColorStore(); + appStore.dimensions.column = 'activity_type'; + + const assignColorsByLocation = () => { + expect(colorStore.colorDimension).toBe('location'); + colorStore.resetColorMapping(); + appStore.filters.location.forEach((loc) => { + ["campaign", "routine"].forEach((activity) => colorStore.getColorsForLine({ + column: activity, + row: appStore.filters.disease[0], + withinBand: loc, + })); + }) + }; + + appStore.dimensions.withinBand = 'location'; + appStore.dimensions.row = 'disease'; + appStore.filters = { + location: ['AFG', 'CHN', globalOption.value], + disease: ['Cholera'], + }; + assignColorsByLocation(); + + expect(colorStore.colorMapping.size).toEqual(3); + expect(colorStore.colorMapping.get(globalOption.value)).toEqual(colors.purple70); + expect(Array.from(colorStore.colorMapping.values())).toEqual( + expect.arrayContaining([colors.magenta50, colors.cyan50, colors.purple70]) + ); + + appStore.filters.location = ['AFG', globalOption.value]; + assignColorsByLocation(); + + expect(colorStore.colorMapping.size).toEqual(2); + expect(colorStore.colorMapping.get(globalOption.value)).toEqual(colors.purple70); + expect(Array.from(colorStore.colorMapping.values())).toEqual( + expect.arrayContaining([colors.purple70, colors.teal50]) + ); + + const assignColorsByDisease = () => { + expect(colorStore.colorDimension).toBe('disease'); + colorStore.resetColorMapping(); + appStore.filters.disease.forEach((disease) => { + ["campaign", "routine"].forEach((activity) => colorStore.getColorsForLine({ + column: activity, + row: appStore.filters.location[0], + withinBand: disease, + })); + }) + } + + appStore.dimensions.withinBand = 'disease'; + appStore.dimensions.row = 'location'; + appStore.filters = { + location: [globalOption.value], + disease: ['Cholera', 'Rubella', 'Measles', 'Rota'], + }; + assignColorsByDisease(); + + expect(colorStore.colorMapping.size).toEqual(4); + expect(Array.from(colorStore.colorMapping.values())).toEqual( + expect.arrayContaining([colors.purple70, colors.cyan90, colors.teal50, colors.magenta50]) + ); + + appStore.filters.disease.push('Typhoid'); + assignColorsByDisease(); + + expect(colorStore.colorMapping.size).toEqual(5); + expect(Array.from(colorStore.colorMapping.values())).toEqual( + expect.arrayContaining([colors.purple70, colors.cyan50, colors.teal70, colors.magenta70, colors.red90]) + ); + + appStore.filters.disease.push('HPV'); + assignColorsByDisease(); + + expect(colorStore.colorMapping.size).toEqual(6); + expect(Array.from(colorStore.colorMapping.values())).toEqual( + expect.arrayContaining([ + colors.purple70, + colors.cyan50, + colors.teal70, + colors.magenta70, + colors.red50, + colors.red90, + ]) + ); + + appStore.filters.disease = diseaseOptions.map(o => o.value); + assignColorsByDisease(); + + expect(colorStore.colorMapping.size).toEqual(16); + expect(Array.from(colorStore.colorMapping.values())).toEqual( + expect.arrayContaining(Object.values(colors)) + ); + expect(Array.from(colorStore.colorMapping.values())).not.toContain(undefined); + const diseaseWithWhiteColor = diseaseOptions.map(o => o.value).at(-1); + expect(colorStore.getColorsForLine({ row: 'AFG', withinBand: diseaseWithWhiteColor })).toEqual({ + fillColor: colors.white, + strokeColor: colors.black, + }); + }); +}); diff --git a/tests/unit/stores/dataStore.spec.ts b/tests/unit/stores/dataStore.spec.ts index 0232284..0501fc8 100644 --- a/tests/unit/stores/dataStore.spec.ts +++ b/tests/unit/stores/dataStore.spec.ts @@ -23,7 +23,7 @@ const expectLastNCallsToEqual = (spy: Mock, args: any[]) => { ); } -describe('useData', () => { +describe('data store', () => { beforeEach(() => { setActivePinia(createPinia()); }); @@ -51,7 +51,7 @@ describe('useData', () => { // Change options: round 1 expect(appStore.exploreBy).toEqual("location"); expect(appStore.focus).toEqual("global"); - appStore.focus = "Central and Southern Asia"; + appStore.focus = "Middle Africa"; expectedFetches += 2; appStore.burdenMetric = BurdenMetrics.DALYS; appStore.logScaleEnabled = false; diff --git a/tests/unit/utils/fileParse.spec.ts b/tests/unit/utils/fileParse.spec.ts new file mode 100644 index 0000000..76a1324 --- /dev/null +++ b/tests/unit/utils/fileParse.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { getDimensionCategoryValue } from '@/utils/fileParse'; + +describe('fileParse utils', () => { + it('getDimensionCategoryValue can parse a data row with a location', () => { + const dataRow = { + location: 'USA', + disease: 'Flu', + }; + + expect(getDimensionCategoryValue('location', dataRow)).toBe('USA'); + expect(getDimensionCategoryValue('disease', dataRow)).toBe('Flu'); + }); + + it('getDimensionCategoryValue can parse a data row without a location', () => { + const dataRow = { + disease: 'Flu', + }; + + expect(getDimensionCategoryValue('location', dataRow)).toBe('global'); + expect(getDimensionCategoryValue('disease', dataRow)).toBe('Flu'); + }); +}); diff --git a/tests/unit/utils/options.spec.ts b/tests/unit/utils/options.spec.ts new file mode 100644 index 0000000..c4c7761 --- /dev/null +++ b/tests/unit/utils/options.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { dimensionOptionLabel } from '@/utils/options'; + +describe('options utils', () => { + it('dimensionOptionLabel can get a label from a value and dimension', () => { + expect(dimensionOptionLabel('location', 'UKR')).toBe('Ukraine'); + expect(dimensionOptionLabel('location', 'global')).toBe('All 117 VIMC countries'); + expect(dimensionOptionLabel('location', 'Middle Africa')).toBe('Middle Africa'); + expect(dimensionOptionLabel('disease', 'Measles')).toBe('Measles'); + expect(dimensionOptionLabel('activity_type', 'campaign')).toBe('Campaign'); + }); + + it('if the value is not recognised, dimensionOptionLabel returns the value verbatim', () => { + expect(dimensionOptionLabel('location', 'kitchen')).toBe('kitchen'); + }); +}); diff --git a/tests/unit/utils/regions.spec.ts b/tests/unit/utils/regions.spec.ts new file mode 100644 index 0000000..b0acc68 --- /dev/null +++ b/tests/unit/utils/regions.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { getCountryName, getSubregionFromCountry } from '@/utils/regions'; + +describe('regions utils', () => { + it('getSubregionFromCountry can get the subregion for a country', () => { + expect(getSubregionFromCountry('UKR')).toBe('Eastern and Southern Europe'); + }); + + it('getSubregionFromCountry returns an empty string for an unrecognised country', () => { + expect(getSubregionFromCountry('kitchen')).toBe(''); + }); + + it('getCountryName can get the country name for a country', () => { + expect(getCountryName('UKR')).toBe('Ukraine'); + }); + + it('getCountryName returns an empty string for an unrecognised country', () => { + expect(getCountryName('kitchen')).toBe(''); + }); +}); diff --git a/tests/unit/utils/titleCase.spec.ts b/tests/unit/utils/titleCase.spec.ts new file mode 100644 index 0000000..7708d97 --- /dev/null +++ b/tests/unit/utils/titleCase.spec.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import titleCase from '@/utils/titleCase'; + +describe('titleCase util', () => { + it('converts strings to title case', () => { + expect(titleCase('hello world')).toBe('Hello World'); + expect(titleCase('TITLE CASE')).toBe('Title Case'); + expect(titleCase('mIxEd CaSe StRiNg')).toBe('Mixed Case String'); + expect(titleCase('single')).toBe('Single'); + expect(titleCase('')).toBe(''); + }); +});