From e765742e11f49d01ffc00d8acb517f0f0eb56f9c Mon Sep 17 00:00:00 2001 From: Greg Harris Date: Thu, 25 Sep 2025 16:43:40 +0100 Subject: [PATCH 1/3] Adding meter component using react-gauge-component. #125 --- package-lock.json | 818 +++++++++++++++++++- package.json | 1 + src/ui/widgets/EmbeddedDisplay/bobParser.ts | 7 +- src/ui/widgets/EmbeddedDisplay/opiParser.ts | 7 +- src/ui/widgets/Meter/meter.test.tsx | 187 +++++ src/ui/widgets/Meter/meter.tsx | 201 +++++ src/ui/widgets/Meter/meterUtilities.test.ts | 327 ++++++++ src/ui/widgets/Meter/meterUtilities.ts | 242 ++++++ src/ui/widgets/index.ts | 1 + 9 files changed, 1776 insertions(+), 15 deletions(-) create mode 100644 src/ui/widgets/Meter/meter.test.tsx create mode 100644 src/ui/widgets/Meter/meter.tsx create mode 100644 src/ui/widgets/Meter/meterUtilities.test.ts create mode 100644 src/ui/widgets/Meter/meterUtilities.ts diff --git a/package-lock.json b/package-lock.json index 4e17029..b3d3cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "jsdom": "^25.0.1", "loglevel": "^1.9.2", "prettier": "^3.3.3", + "react-gauge-component": "^1.2.64", "react-id-generator": "^3.0.2", "react-test-renderer": "^18.3.1", "react-tiny-popover": "^8.1.2", @@ -5244,6 +5245,13 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "peer": true }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, "node_modules/abs-svg-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", @@ -5313,6 +5321,38 @@ "resolved": "https://registry.npmjs.org/almost-equal/-/almost-equal-1.1.0.tgz", "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==" }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5423,6 +5463,34 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5874,6 +5942,55 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6088,6 +6205,16 @@ "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", "dev": true }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6125,6 +6252,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -6196,6 +6336,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -6265,6 +6421,19 @@ "node": ">=0.8.0" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clipboard-copy": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clipboard-copy/-/clipboard-copy-4.0.1.tgz", @@ -6285,6 +6454,24 @@ } ] }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6409,6 +6596,55 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6465,6 +6701,16 @@ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", "dev": true }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -6564,7 +6810,6 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", "dev": true, - "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7599,6 +7844,16 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7881,6 +8136,13 @@ "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.52", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.52.tgz", @@ -8901,6 +9163,30 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", @@ -8949,8 +9235,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "peer": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -9908,6 +10193,16 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -10238,6 +10533,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10278,6 +10589,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", @@ -10414,6 +10735,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -10472,6 +10806,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -10567,6 +10914,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -10576,8 +10936,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/isomorphic-timers-promises": { "version": "1.0.1", @@ -11165,8 +11524,7 @@ "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "peer": true + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" }, "node_modules/merge2": { "version": "1.4.1", @@ -11240,6 +11598,16 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -11406,6 +11774,16 @@ "ms": "^2.1.1" } }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -11528,6 +11906,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -11683,6 +12074,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11691,6 +12092,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optimism": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.0.tgz", @@ -11928,12 +12345,18 @@ "node": ">=0.10.0" } }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -12929,6 +13352,49 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -12954,6 +13420,23 @@ "react": "^18.3.1" } }, + "node_modules/react-gauge-component": { + "version": "1.2.64", + "resolved": "https://registry.npmjs.org/react-gauge-component/-/react-gauge-component-1.2.64.tgz", + "integrity": "sha512-kYpOZtfu8z1Xjo+GrCQlMnh3Ai39j/ol1wzXEfvm9pWMyFmhFsX+d3cnZqnRBRQXsKif1up9378RrFjy47Q9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "d3": "^7.6.1", + "lodash": "^4.17.21", + "serve": "^14.2.3" + }, + "peerDependencies": { + "react": "^16.8.2 || ^17.0 || ^18.x || ^19.x", + "react-dom": "^16.8.2 || ^17.0 || ^18.x || ^19.x" + } + }, "node_modules/react-id-generator": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/react-id-generator/-/react-id-generator-3.0.2.tgz", @@ -13360,6 +13843,30 @@ "regjsparser": "bin/parser" } }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", @@ -13480,6 +13987,16 @@ } } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -13873,6 +14390,125 @@ "randombytes": "^2.1.0" } }, + "node_modules/serve": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", + "integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.12.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.6", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.2", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/serve/node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/serve/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13934,7 +14570,6 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -13947,7 +14582,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -13976,6 +14610,13 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/signum": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", @@ -14202,6 +14843,53 @@ "parenthesis": "^3.1.5" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -14306,7 +14994,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -14323,6 +15010,16 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -15065,6 +15762,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, "node_modules/update-diff": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", @@ -15074,7 +15782,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "peer": true, "dependencies": { "punycode": "^2.1.0" } @@ -15166,6 +15873,16 @@ "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", "peer": true }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.10", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", @@ -15608,7 +16325,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -15720,6 +16436,22 @@ "node": ">=8" } }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -15738,6 +16470,66 @@ "object-assign": "^4.1.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 83580bd..efdfe53 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "jsdom": "^25.0.1", "loglevel": "^1.9.2", "prettier": "^3.3.3", + "react-gauge-component": "^1.2.64", "react-id-generator": "^3.0.2", "react-test-renderer": "^18.3.1", "react-tiny-popover": "^8.1.2", diff --git a/src/ui/widgets/EmbeddedDisplay/bobParser.ts b/src/ui/widgets/EmbeddedDisplay/bobParser.ts index 43b84c0..d65cb00 100644 --- a/src/ui/widgets/EmbeddedDisplay/bobParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/bobParser.ts @@ -56,6 +56,7 @@ const BOB_WIDGET_MAPPING: { [key: string]: any } = { rectangle: "shape", tank: "tank", thermometer: "thermometer", + meter: "meter", choice: "choicebutton", scaledslider: "slidecontrol", symbol: "symbol" @@ -85,6 +86,7 @@ export const WIDGET_DEFAULT_SIZES: { [key: string]: [number, number] } = { rectangle: [100, 20], tank: [150, 200], thermometer: [40, 160], + meter: [240, 120], scaledslider: [400, 55], symbol: [100, 100] }; @@ -381,6 +383,7 @@ export function parseBob( symbols: ["symbols", bobParseSymbols], initialIndex: ["initial_index", bobParseNumber], showIndex: ["show_index", opiParseBoolean], + showValue: ["show_value", opiParseBoolean], fallbackSymbol: ["fallback_symbol", opiParseString], rotation: ["rotation", bobParseNumber], styleOpt: ["style", bobParseNumber], @@ -401,7 +404,9 @@ export function parseBob( majorTickStepHint: ["major_tick_step_hint", bobParseNumber], maximum: ["maximum", bobParseNumber], minimum: ["minimum", bobParseNumber], - emptyColor: ["empty_color", opiParseColor] + format: ["format", bobParseNumber], + emptyColor: ["empty_color", opiParseColor], + needleColor: ["needle_color", opiParseColor] }; const complexParsers = { diff --git a/src/ui/widgets/EmbeddedDisplay/opiParser.ts b/src/ui/widgets/EmbeddedDisplay/opiParser.ts index d92e8a3..ccc414d 100644 --- a/src/ui/widgets/EmbeddedDisplay/opiParser.ts +++ b/src/ui/widgets/EmbeddedDisplay/opiParser.ts @@ -73,6 +73,7 @@ const OPI_WIDGET_MAPPING: { [key: string]: any } = { "org.csstudio.opibuilder.widgets.progressbar": "progressbar", "org.csstudio.opibuilder.widgets.tank": "tank", "org.csstudio.opibuilder.widgets.thermometer": "thermometer", + "org.csstudio.opibuilder.widgets.tmeter": "meter", "org.csstudio.opibuilder.widgets.LED": "led", "org.csstudio.opibuilder.widgets.Image": "image", "org.csstudio.opibuilder.widgets.edm.symbolwidget": "pngsymbol", @@ -642,11 +643,13 @@ export const OPI_SIMPLE_PARSERS: ParserDict = { onColor: ["on_color", opiParseColor], offColor: ["off_color", opiParseColor], fillColor: ["fill_color", opiParseColor], + needleColor: ["needle_color", opiParseColor], precision: ["precision", opiParseNumber], formatType: ["format_type", opiParseFormatType], precisionFromPv: ["precision_from_pv", opiParseBoolean], visible: ["visible", opiParseBoolean], showUnits: ["show_units", opiParseBoolean], + showValue: ["show_value_label", opiParseBoolean], scaleVisible: ["scale_visible", opiParseBoolean], transparent: ["transparent", opiParseBoolean], horizontal: ["horizontal", opiParseBoolean], @@ -713,7 +716,9 @@ export const OPI_SIMPLE_PARSERS: ParserDict = { selectedColor: ["selected_color", opiParseColor], enabled: ["enabled", opiParseBoolean], resize: ["resize_behaviour", opiParseResizing], - labelsFromPv: ["labels_from_pv", opiParseBoolean] + labelsFromPv: ["labels_from_pv", opiParseBoolean], + limitsFromPv: ["limits_from_pv", opiParseBoolean], + format: ["format_type", opiParseNumber] }; /** diff --git a/src/ui/widgets/Meter/meter.test.tsx b/src/ui/widgets/Meter/meter.test.tsx new file mode 100644 index 0000000..45f00bf --- /dev/null +++ b/src/ui/widgets/Meter/meter.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { MeterComponent } from "./meter"; +import { Color } from "../../../types/color"; +import { NumberFormatEnum } from "./meterUtilities"; +import * as meterUtilities from "./meterUtilities"; +import { DType, Font } from "../../../types"; + +vi.mock("react-gauge-component", () => ({ + GaugeComponent: vi.fn( + ({ value, minValue, maxValue, pointer, arc, labels }) => ( +
+
+
+
+
+ ) + ) +})); + +vi.mock("./meterUtilities", async () => { + const actual = await vi.importActual("./meterUtilities"); + return { + ...actual, + formatValue: vi.fn( + (value, format, precision, units, showUnits) => () => `${value}` + ), + buildSubArcs: vi.fn(() => [{ color: "red", start: 0, end: 100 }]), + createIntervals: vi.fn(() => [0, 25, 50, 75, 100]), + convertInfAndNanToUndefined: vi.fn(val => val) + }; +}); + +describe("MeterComponent", () => { + const defaultProps = { + connected: false, + readonly: true, + pvName: "PV:Test", + value: { + getDoubleValue: () => 50, + display: { + units: "kW", + controlRange: { min: 0, max: 100 }, + alarmRange: { min: 80, max: 100 }, + warningRange: { min: 60, max: 80 } + } + } as Partial as DType, + foregroundColor: Color.fromRgba(0, 0, 0, 1), + needleColor: Color.fromRgba(255, 5, 7, 1), + backgroundColor: Color.fromRgba(250, 250, 250, 1) + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default props", () => { + render(); + + const gaugeComponent = screen.getByTestId("gauge-component"); + expect(gaugeComponent).toBeInTheDocument(); + expect(gaugeComponent.getAttribute("data-value")).toBe("50"); + expect(gaugeComponent.getAttribute("data-min")).toBe("0"); + expect(gaugeComponent.getAttribute("data-max")).toBe("100"); + }); + + it("uses custom min/max values when limitsFromPv is false", () => { + render( + + ); + + const gaugeComponent = screen.getByTestId("gauge-component"); + expect(gaugeComponent.getAttribute("data-min")).toBe("-50"); + expect(gaugeComponent.getAttribute("data-max")).toBe("150"); + }); + + it("uses PV limits when limitsFromPv is true", () => { + render( + + ); + + const gaugeComponent = screen.getByTestId("gauge-component"); + expect(gaugeComponent.getAttribute("data-min")).toBe("0"); // From controlRange.min + expect(gaugeComponent.getAttribute("data-max")).toBe("100"); // From controlRange.max + }); + + it("uses transparent background when transparent is true", () => { + render(); + + const box = screen.getByTestId("gauge-component").parentElement; + expect(box).toHaveStyle("background-color: rgba(0, 0, 0, 0)"); + }); + + it("hides value when showValue is false", () => { + render(); + + const valueLabel = screen.getByTestId("value-label"); + expect(valueLabel.getAttribute("data-hide")).toBe("true"); + }); + + it("calls formatValue with correct parameters", () => { + render( + + ); + + expect(meterUtilities.formatValue).toHaveBeenCalledWith( + 50, + NumberFormatEnum.Exponential, + 2, + "kW", + true + ); + }); + + it("calls buildSubArcs with correct parameters", () => { + render(); + + expect(meterUtilities.buildSubArcs).toHaveBeenCalledWith( + "rgba(0,0,0,1)", + 0, + 100, + 80, + 60, + 80, + 100 + ); + }); + + it("handles missing PV value gracefully", () => { + render(); + + const gaugeComponent = screen.getByTestId("gauge-component"); + expect(gaugeComponent.getAttribute("data-value")).toBe("0"); + }); + + it("scales width correctly based on height/width ratio", () => { + render(); + + const box = screen.getByTestId("gauge-component").parentElement; + expect(box).toHaveStyle("width: 190px"); // 1.9 * height + }); + + it("uses default width when width/height ratio is less than 1.9", () => { + render(); + + const box = screen.getByTestId("gauge-component").parentElement; + expect(box).toHaveStyle("width: 150px"); // original width + }); + + it("applies font styles correctly", () => { + const font = { + css: () => ({ fontFamily: "Arial" }) + } as Partial as Font; + + render(); + + const valueLabel = screen.getByTestId("value-label"); + + const labelStyle = valueLabel.getAttribute("data-fontFamily"); + expect(labelStyle).toBe("Arial"); + }); +}); diff --git a/src/ui/widgets/Meter/meter.tsx b/src/ui/widgets/Meter/meter.tsx new file mode 100644 index 0000000..687b068 --- /dev/null +++ b/src/ui/widgets/Meter/meter.tsx @@ -0,0 +1,201 @@ +import React, { useMemo } from "react"; +import { Box } from "@mui/material"; +import { Widget } from "../widget"; +import { PVInputComponent, PVWidgetPropType } from "../widgetProps"; +import { registerWidget } from "../register"; +import { + FloatPropOpt, + BoolPropOpt, + IntPropOpt, + InferWidgetProps, + FontPropOpt, + ColorPropOpt +} from "../propTypes"; +import { Color } from "../../../types/color"; +import { GaugeComponent } from "react-gauge-component"; +import { + buildSubArcs, + convertInfAndNanToUndefined, + createIntervals, + formatValue, + NumberFormatEnum +} from "./meterUtilities"; + +export const MeterProps = { + minimum: FloatPropOpt, + maximum: FloatPropOpt, + limitsFromPv: BoolPropOpt, + format: IntPropOpt, + foregroundColor: ColorPropOpt, + backgroundColor: ColorPropOpt, + needleColor: ColorPropOpt, + precision: IntPropOpt, + font: FontPropOpt, + transparent: BoolPropOpt, + showUnits: BoolPropOpt, + showValue: BoolPropOpt, + width: FloatPropOpt, + height: FloatPropOpt +}; + +export const MeterComponent = ( + props: InferWidgetProps & PVInputComponent +): JSX.Element => { + const { + value, + format = NumberFormatEnum.Default, + height = 120, + width = 240, + limitsFromPv = true, + font, + foregroundColor = Color.fromRgba(0, 0, 0, 1), + needleColor = Color.fromRgba(255, 5, 7, 1), + precision = undefined, + showUnits = true, + showValue = true, + transparent = false + } = props; + const units = value?.display.units ?? ""; + const numValue = value?.getDoubleValue() ?? 0; + + const getFormattedValue = useMemo( + () => formatValue(numValue, format, precision ?? 3, units, showUnits), + [numValue, format, precision, units, showUnits] + ); + + const backgroundColor = transparent + ? "transparent" + : (props.backgroundColor?.toString() ?? "rgba(250, 250, 250, 1)"); + + const display = value?.display; + const alarmRangeMin = convertInfAndNanToUndefined(display?.alarmRange?.min); + const alarmRangeMax = convertInfAndNanToUndefined(display?.alarmRange?.max); + const warningRangeMin = convertInfAndNanToUndefined( + display?.warningRange?.min + ); + const warningRangeMax = convertInfAndNanToUndefined( + display?.warningRange?.max + ); + + let { minimum = 0, maximum = 100 } = props; + if (limitsFromPv) { + const pvMin = + display?.controlRange?.min ?? alarmRangeMin ?? warningRangeMin; + const pvMax = + display?.controlRange?.max ?? alarmRangeMax ?? warningRangeMax; + minimum = pvMin ?? 0; + maximum = pvMax ?? 100; + } + + // For a semi semicircle height / width is 2, but allow extra height for some padding + const scaledWidth = width / height > 1.9 ? 1.9 * height : width; + + return ( + + + string }; + } => ({ + value: x, + valueConfig: { + formatTextValue: formatValue(x, format, 2, "", false) + } + }) + ), + defaultTickValueConfig: { + style: { + fill: foregroundColor.toString(), + fontSize: `${scaledWidth * 0.04}px`, + textShadow: "none", + fontFamily: font?.css().fontFamily + } + }, + defaultTickLineConfig: { + color: foregroundColor.toString() + } + } + }} + /> + + + ); +}; + +const MeterWidgetProps = { + ...MeterProps, + ...PVWidgetPropType +}; + +export const Meter = ( + props: InferWidgetProps +): JSX.Element => ; + +registerWidget(Meter, MeterWidgetProps, "meter"); diff --git a/src/ui/widgets/Meter/meterUtilities.test.ts b/src/ui/widgets/Meter/meterUtilities.test.ts new file mode 100644 index 0000000..6543821 --- /dev/null +++ b/src/ui/widgets/Meter/meterUtilities.test.ts @@ -0,0 +1,327 @@ +import { describe, it, expect } from "vitest"; +import { + NumberFormatEnum, + convertInfAndNanToUndefined, + formatValue, + buildSubArcs, + createIntervals +} from "./meterUtilities"; + +describe("convertInfAndNanToUndefined", () => { + it("should return the same value for finite numbers", () => { + expect(convertInfAndNanToUndefined(42)).toBe(42); + expect(convertInfAndNanToUndefined(0)).toBe(0); + expect(convertInfAndNanToUndefined(-10.5)).toBe(-10.5); + }); + + it("should return undefined for null or undefined", () => { + expect(convertInfAndNanToUndefined(undefined)).toBeUndefined(); + expect( + convertInfAndNanToUndefined(null as unknown as undefined) + ).toBeUndefined(); + }); + + it("should return undefined for Infinity and NaN", () => { + expect(convertInfAndNanToUndefined(Infinity)).toBeUndefined(); + expect(convertInfAndNanToUndefined(-Infinity)).toBeUndefined(); + expect(convertInfAndNanToUndefined(NaN)).toBeUndefined(); + }); +}); + +describe("formatValue", () => { + it("should format values with default format", () => { + const formatter = formatValue(123.456, NumberFormatEnum.Default, 2, "V"); + expect(formatter()).toBe("123.46"); + + const formatterWithUnits = formatValue( + 123.456, + NumberFormatEnum.Default, + 2, + "V", + true + ); + expect(formatterWithUnits()).toBe("123.46 V"); + }); + + it("should format values with exponential format", () => { + const formatter = formatValue( + 123.456, + NumberFormatEnum.Exponential, + 3, + "V" + ); + expect(formatter()).toBe("1.23e+2"); + + const formatterWithUnits = formatValue( + 123.456, + NumberFormatEnum.Exponential, + 3, + "V", + true + ); + expect(formatterWithUnits()).toBe("1.23e+2 V"); + }); + + it("should format values with engineering format", () => { + const formatter = formatValue( + 123.456, + NumberFormatEnum.Engineering, + 3, + "V" + ); + expect(formatter()).toBe("123"); + + const formatter2 = formatValue( + 123.456, + NumberFormatEnum.Engineering, + 4, + "V" + ); + expect(formatter2()).toBe("123.5"); + }); + + it("should format values with hexadecimal format", () => { + const formatter = formatValue( + 123.456, + NumberFormatEnum.Hexadecimal, + 2, + "V" + ); + expect(formatter()).toBe("0x7b"); + + const formatterWithUnits = formatValue( + 255, + NumberFormatEnum.Hexadecimal, + 2, + "V", + true + ); + expect(formatterWithUnits()).toBe("0xff V"); + }); + + it("should use default precision when precision is -1", () => { + const formatter = formatValue(123.456, NumberFormatEnum.Default, -1, "V"); + expect(formatter()).toBe("123.456"); + }); +}); + +describe("buildSubArcs", () => { + it("should build sub arcs with all ranges defined", () => { + const subArcs = buildSubArcs("blue", 0, 100, 10, 20, 80, 90); + + expect(subArcs).toHaveLength(5); + expect(subArcs[0].limit).toBe(10); + expect(subArcs[0].color).toBe("rgba(255, 0, 0, 1)"); + + expect(subArcs[1].limit).toBe(20); + expect(subArcs[1].color).toBe("rgba(255, 128, 0, 1)"); + + expect(subArcs[2].limit).toBe(80); + expect(subArcs[2].color).toBe("blue"); + + expect(subArcs[3].limit).toBe(90); + expect(subArcs[3].color).toBe("rgba(255, 128, 0, 1)"); + + expect(subArcs[4].limit).toBe(100); + expect(subArcs[4].color).toBe("rgba(255, 0, 0, 1)"); + }); + + it("should build sub arcs with only warning ranges", () => { + const subArcs = buildSubArcs("green", 0, 100, undefined, 20, 80, undefined); + + expect(subArcs).toHaveLength(3); + expect(subArcs[0].limit).toBe(20); + expect(subArcs[0].color).toBe("rgba(255, 128, 0, 1)"); + + expect(subArcs[1].limit).toBe(80); + expect(subArcs[1].color).toBe("green"); + + expect(subArcs[2].limit).toBe(100); + expect(subArcs[2].color).toBe("rgba(255, 128, 0, 1)"); + }); + + it("should build sub arcs with only alarm ranges", () => { + const subArcs = buildSubArcs("red", 0, 100, 10, undefined, undefined, 90); + + expect(subArcs).toHaveLength(3); + expect(subArcs[0].limit).toBe(10); + expect(subArcs[0].color).toBe("rgba(255, 0, 0, 1)"); + + expect(subArcs[1].limit).toBe(90); + expect(subArcs[1].color).toBe("red"); + + expect(subArcs[2].limit).toBe(100); + expect(subArcs[2].color).toBe("rgba(255, 0, 0, 1)"); + }); + + it("should build sub arcs when low value alarm range greater then low value warning range", () => { + const subArcs = buildSubArcs("green", 0, 100, 20, 10, 80, 90); + + expect(subArcs).toHaveLength(4); + expect(subArcs[0].limit).toBe(20); + expect(subArcs[0].color).toBe("rgba(255, 0, 0, 1)"); + + expect(subArcs[1].limit).toBe(80); + expect(subArcs[1].color).toBe("green"); + + expect(subArcs[2].limit).toBe(90); + expect(subArcs[2].color).toBe("rgba(255, 128, 0, 1)"); + + expect(subArcs[3].limit).toBe(100); + expect(subArcs[3].color).toBe("rgba(255, 0, 0, 1)"); + }); + + it("should build sub arcs when high value alarm range lower than high value warning range", () => { + const subArcs = buildSubArcs("green", 0, 100, 10, 20, 80, 70); + + expect(subArcs).toHaveLength(4); + expect(subArcs[0].limit).toBe(10); + expect(subArcs[0].color).toBe("rgba(255, 0, 0, 1)"); + + expect(subArcs[1].limit).toBe(20); + expect(subArcs[1].color).toBe("rgba(255, 128, 0, 1)"); + + expect(subArcs[2].limit).toBe(70); + expect(subArcs[2].color).toBe("green"); + + expect(subArcs[3].limit).toBe(100); + expect(subArcs[3].color).toBe("rgba(255, 0, 0, 1)"); + }); + + it("should build sub arcs with no ranges", () => { + const subArcs = buildSubArcs( + "purple", + 0, + 100, + undefined, + undefined, + undefined, + undefined + ); + + expect(subArcs).toHaveLength(1); + expect(subArcs[0].limit).toBe(100); + expect(subArcs[0].color).toBe("purple"); + }); + + it("should clamp values to min/max bounds", () => { + const subArcs = buildSubArcs("blue", 50, 150, 0, 60, 160, 200); + + expect(subArcs[0].limit).toBe(60); // upper limit of low value alarm range + expect(subArcs[2].limit).toBe(150); // Maximum value + }); +}); + +describe("createIntervals", () => { + it("should create intervals between min and max values", () => { + const intervals = createIntervals(0, 100); + expect(intervals.length).toBe(11); + expect(intervals.length).toBeLessThanOrEqual(16); + expect(intervals[0]).toBe(0); + expect(intervals[intervals.length - 1]).toBe(100); + }); + + it("should create intervals for decimal ranges", () => { + const intervals = createIntervals(0.1, 0.9); + expect(intervals.length).toBe(9); + expect(intervals.length).toBeLessThanOrEqual(16); + expect(intervals[0]).toBe(0.1); + expect(intervals[intervals.length - 1]).toBe(0.9); + }); + + it("should create intervals for negative ranges", () => { + const intervals = createIntervals(-100, -10); + expect(intervals.length).toBe(10); + expect(intervals.length).toBeLessThanOrEqual(16); + expect(intervals[0]).toBe(-100); + expect(intervals[intervals.length - 1]).toBe(-10); + }); + + it("should create intervals for mixed ranges", () => { + const intervals = createIntervals(-50, 50); + expect(intervals.length).toBe(11); + expect(intervals.length).toBeLessThanOrEqual(16); + expect(intervals[0]).toBe(-50); + expect(intervals[intervals.length - 1]).toBe(50); + }); + + it("should handle very small ranges", () => { + const intervals = createIntervals(1, 1.0001); + expect(intervals.length).toBe(11); + expect(intervals[0]).toBe(1); + expect(intervals[intervals.length - 1]).toBe(1.0001); + }); + + it("should throw error when min is greater than or equal to max", () => { + expect(() => createIntervals(10, 5)).toThrow( + "Minimum value must be less than maximum value" + ); + expect(() => createIntervals(5, 5)).toThrow( + "Minimum value must be less than maximum value" + ); + }); + + it("should handle very large ranges", () => { + const intervals = createIntervals(0, 1000000); + expect(intervals.length).toBe(11); + expect(intervals.length).toBeLessThanOrEqual(16); + expect(intervals[0]).toBe(0); + expect(intervals[intervals.length - 1]).toBe(1000000); + }); + + it("should handle very small exponential ranges", () => { + const intervals = createIntervals(1.0e-12, 1.0e9); + expect(intervals.length).toBe(11); + expect(intervals[0]).toBe(0); // note non-log scale + expect(intervals[intervals.length - 1]).toBe(1.0e9); + }); + + it("should create intervals of spacing 0.5, when the range is 6", () => { + const intervals = createIntervals(20, 26); + expect(intervals.length).toBe(13); + expect(intervals[0]).toBe(20); + expect(intervals[intervals.length - 1]).toBe(26); + }); + + it("should create intervals of spacing 1, when the range is 7", () => { + const intervals = createIntervals(20, 27); + expect(intervals.length).toBe(8); + expect(intervals[0]).toBe(20); + expect(intervals[intervals.length - 1]).toBe(27); + }); + + it("should create intervals of spacing 2 when the range is 16", () => { + const intervals = createIntervals(10, 26); + expect(intervals.length).toBe(9); + expect(intervals[0]).toBe(10); + expect(intervals[intervals.length - 1]).toBe(26); + }); + + it("should create intervals of spacing 2.5 when the range is 25", () => { + const intervals = createIntervals(10, 35); + expect(intervals.length).toBe(11); + expect(intervals[0]).toBe(10); + expect(intervals[intervals.length - 1]).toBe(35); + }); + + it("should create intervals of spacing 5 when the range is 45", () => { + const intervals = createIntervals(10, 55); + expect(intervals.length).toBe(10); + expect(intervals[0]).toBe(10); + expect(intervals[intervals.length - 1]).toBe(55); + }); + + it("should create intervals of spacing 10 when the range is 90", () => { + const intervals = createIntervals(10, 100); + expect(intervals.length).toBe(10); + expect(intervals[0]).toBe(10); + expect(intervals[intervals.length - 1]).toBe(100); + }); + + it("should create intervals of spacing 20 when the range is 160", () => { + const intervals = createIntervals(10, 170); + expect(intervals.length).toBe(10); + expect(intervals[0]).toBe(10); + expect(intervals[intervals.length - 1]).toBe(170); + }); +}); diff --git a/src/ui/widgets/Meter/meterUtilities.ts b/src/ui/widgets/Meter/meterUtilities.ts new file mode 100644 index 0000000..8796bd6 --- /dev/null +++ b/src/ui/widgets/Meter/meterUtilities.ts @@ -0,0 +1,242 @@ +export enum NumberFormatEnum { + Default = 1, + Exponential = 2, + Engineering = 3, + Hexadecimal = 4 +} + +/** + * Convert inf and NaN Number values to undefined + * @param value The value + * @returns The value or undefined if the original value was undefined, null, inf, or NaN + */ +export const convertInfAndNanToUndefined = ( + value: number | undefined +): number | undefined => { + // u + if (value === null || value === undefined) { + return undefined; + } + + return isFinite(value) ? value : undefined; +}; + +/** + * Convert a number to a string of the given form, with or without the specified units + * @param value The numerical value + * @param numberFormat The format number + * @param precision The desired precision + * @param units the units + * @param showUnits True if units are to be shown, default false no units shown + * @returns The formatted value + */ +export const formatValue = + ( + value: number, + numberFormat: number, + precision: number, + units: string, + showUnits = false + ) => + (): string => { + const maxPrecision = precision === -1 ? 3 : precision; + let strVal = `${value}`; + if (numberFormat === NumberFormatEnum.Exponential) { + strVal = value.toExponential(maxPrecision - 1); + } else if (numberFormat === NumberFormatEnum.Engineering) { + strVal = value.toPrecision(maxPrecision); + } else if (numberFormat === NumberFormatEnum.Hexadecimal) { + strVal = `0x${Math.round(value).toString(16)}`; + } else { + strVal = value.toFixed(maxPrecision); + } + + return showUnits ? `${strVal} ${units}` : strVal; + }; + +/** + * Creates the alert, warning and normal, colored sub arcs for the meter + * + * @param foregroundColor The minimum value of the range + * @param maximum The maximum value of the range + * @param minimum The minimum value of the range + * @param alarmRangeMin The upper boundary of the low value alert range + * @param warningRangeMin The upper boundary of the low value warning range + * @param warningRangeMax The lower boundary of the high value warning range + * @param alarmRangeMax The lower boundary of the high value alert range + * @returns An array of sub arc for the meter + */ +export const buildSubArcs = ( + foregroundColor: string, + minimum: number, + maximum: number, + alarmRangeMin: number | undefined, + warningRangeMin: number | undefined, + warningRangeMax: number | undefined, + alarmRangeMax: number | undefined +) => { + const subArcs = []; + + const withinBounds = (value: number) => + Math.min(maximum, Math.max(minimum, value)); + + if (alarmRangeMin && alarmRangeMin > minimum) { + subArcs.push({ + limit: withinBounds(alarmRangeMin), + width: 0.04, + showTick: false, + color: "rgba(255, 0, 0, 1)" + }); + } + + if (warningRangeMin && warningRangeMin > (alarmRangeMin ?? minimum)) { + subArcs.push({ + limit: withinBounds(warningRangeMin), + width: 0.04, + showTick: false, + color: "rgba(255, 128, 0, 1)" + }); + } + + subArcs.push({ + limit: withinBounds( + Math.min(warningRangeMax ?? maximum, alarmRangeMax ?? maximum) + ), + width: 0.02, + showTick: false, + color: foregroundColor + }); + + if (warningRangeMax && warningRangeMax < (alarmRangeMax ?? maximum)) { + subArcs.push({ + limit: withinBounds(alarmRangeMax ?? maximum), + width: 0.04, + showTick: false, + color: "rgba(255, 128, 0, 1)" + }); + } + + if (alarmRangeMax && alarmRangeMax < maximum) { + subArcs.push({ + limit: withinBounds(maximum), + width: 0.04, + showTick: false, + color: "rgba(255, 0, 0, 1)" + }); + } + + return subArcs; +}; + +/** + * Creates interval boundaries between min and max values + * + * @param min The minimum value of the range + * @param max The maximum value of the range + * @returns An array of interval boundary values + */ +export const createIntervals = (min: number, max: number): number[] => { + if (min >= max) { + throw new Error("Minimum value must be less than maximum value"); + } + + const range = max - min; + + // Tiny ranges + if (range < Number.EPSILON) { + return [min, max]; + } + + // Determine the scale factor based on the range + let scale = 1; + let decimalPlaces = 1; + + if (range < 1) { + // For decimal ranges, find how many decimal places we need + decimalPlaces = Math.ceil(Math.abs(Math.log10(range))); + scale = Math.pow(10, decimalPlaces); + } + + // Scale up the values to work with integers + const scaledMin = min * scale; + const scaledMax = max * scale; + const scaledRange = scaledMax - scaledMin; + + // Find the appropriate "nice" step size + const candidateSteps = findNiceStepSizes(scaledRange); + + let bestScore = Number.NEGATIVE_INFINITY; + let bestIntervals: number[] = []; + + for (const step of candidateSteps) { + const intervals = generateIntervals(scaledMin, scaledMax, step); + + const score = scoreIntervals(intervals.length); + + if (score > bestScore) { + bestScore = score; + bestIntervals = intervals; + } + } + + return bestIntervals.map(val => Number((val / scale).toFixed(decimalPlaces))); +}; + +const findNiceStepSizes = (range: number): number[] => { + const targetIntervals = 10; + const roughStepSize = range / targetIntervals; + + const magnitude = Math.pow(10, Math.floor(Math.log10(roughStepSize))); + + return [ + 0.5 * magnitude, + 1 * magnitude, + 2 * magnitude, + 2.5 * magnitude, + 5 * magnitude, + 10 * magnitude + ].filter(step => step > 0); +}; + +const generateIntervals = ( + min: number, + max: number, + step: number +): number[] => { + const intervals: number[] = []; + + intervals.push(min); + + let current = Math.ceil(min / step) * step; + if (Math.abs(current - min) < step * 0.1) { + // If very close to min, move to next step + current += step; + } + + // Add points at step intervals + while (current < max && intervals.length < 20) { + intervals.push(current); + current += step; + } + + if (intervals[intervals.length - 1] !== max) { + intervals.push(max); + } + + return intervals; +}; + +const scoreIntervals = (count: number): number => { + // Ideal + if (count >= 8 && count <= 16) { + return 100 - Math.abs(10 - count); + } + + // Acceptable + if (count >= 5 && count <= 21) { + return 90 - Math.abs(10 - count); + } + + // too many or too few intervals + return 80 - Math.abs(10 - count); +}; diff --git a/src/ui/widgets/index.ts b/src/ui/widgets/index.ts index 9c69e6b..10080b8 100644 --- a/src/ui/widgets/index.ts +++ b/src/ui/widgets/index.ts @@ -29,6 +29,7 @@ export { Polygon } from "./Polygon/polygon"; export { ProgressBar } from "./ProgressBar/progressBar"; export { Tank } from "./Tank/tank"; export { Thermometer } from "./Thermometer/thermometer"; +export { Meter } from "./Meter/meter"; export { SimpleSymbol } from "./SimpleSymbol/simpleSymbol"; export { SlideControl } from "./SlideControl/slideControl"; export { Slideshow } from "./Slideshow/slideshow"; From d59aa32ff050a823d548d9349581b27e7ce5f37f Mon Sep 17 00:00:00 2001 From: Greg Harris Date: Thu, 25 Sep 2025 18:18:48 +0100 Subject: [PATCH 2/3] Improvements to rounding of numbers between 1 and 10000 --- src/ui/widgets/Meter/meter.tsx | 2 +- src/ui/widgets/Meter/meterUtilities.test.ts | 40 +++++++++++++++++++-- src/ui/widgets/Meter/meterUtilities.ts | 12 +++++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/ui/widgets/Meter/meter.tsx b/src/ui/widgets/Meter/meter.tsx index 687b068..698e8f8 100644 --- a/src/ui/widgets/Meter/meter.tsx +++ b/src/ui/widgets/Meter/meter.tsx @@ -166,7 +166,7 @@ export const MeterComponent = ( } => ({ value: x, valueConfig: { - formatTextValue: formatValue(x, format, 2, "", false) + formatTextValue: formatValue(x, 1, 2, "", false) } }) ), diff --git a/src/ui/widgets/Meter/meterUtilities.test.ts b/src/ui/widgets/Meter/meterUtilities.test.ts index 6543821..3ce4817 100644 --- a/src/ui/widgets/Meter/meterUtilities.test.ts +++ b/src/ui/widgets/Meter/meterUtilities.test.ts @@ -31,7 +31,7 @@ describe("convertInfAndNanToUndefined", () => { describe("formatValue", () => { it("should format values with default format", () => { const formatter = formatValue(123.456, NumberFormatEnum.Default, 2, "V"); - expect(formatter()).toBe("123.46"); + expect(formatter()).toBe("123"); const formatterWithUnits = formatValue( 123.456, @@ -40,7 +40,41 @@ describe("formatValue", () => { "V", true ); - expect(formatterWithUnits()).toBe("123.46 V"); + expect(formatterWithUnits()).toBe("123 V"); + }); + + it("should format values with default format, over different orders of magnitude", () => { + const formatter1 = formatValue(1234.56, NumberFormatEnum.Default, 2, "V"); + expect(formatter1()).toBe("1235"); + + const formatter2 = formatValue(12.3456, NumberFormatEnum.Default, 2, "V"); + expect(formatter2()).toBe("12"); + + const formatter3 = formatValue(12345.6, NumberFormatEnum.Default, 2, "V"); + expect(formatter3()).toBe("1.2e+4"); + + const formatter4 = formatValue(0.0123456, NumberFormatEnum.Default, 2, "V"); + expect(formatter4()).toBe("0.012"); + + const formatter5 = formatValue( + 0.000000123456, + NumberFormatEnum.Default, + 2, + "V" + ); + expect(formatter5()).toBe("1.2e-7"); + + const formatter9 = formatValue(-12345.6, NumberFormatEnum.Default, 2, "V"); + expect(formatter9()).toBe("-1.2e+4"); + + const formatter6 = formatValue(-1234.56, NumberFormatEnum.Default, 2, "V"); + expect(formatter6()).toBe("-1235"); + + const formatter7 = formatValue(-123.456, NumberFormatEnum.Default, 2, "V"); + expect(formatter7()).toBe("-123"); + + const formatter8 = formatValue(-12.3456, NumberFormatEnum.Default, 2, "V"); + expect(formatter8()).toBe("-12"); }); it("should format values with exponential format", () => { @@ -101,7 +135,7 @@ describe("formatValue", () => { it("should use default precision when precision is -1", () => { const formatter = formatValue(123.456, NumberFormatEnum.Default, -1, "V"); - expect(formatter()).toBe("123.456"); + expect(formatter()).toBe("123"); }); }); diff --git a/src/ui/widgets/Meter/meterUtilities.ts b/src/ui/widgets/Meter/meterUtilities.ts index 8796bd6..74cb5e3 100644 --- a/src/ui/widgets/Meter/meterUtilities.ts +++ b/src/ui/widgets/Meter/meterUtilities.ts @@ -46,9 +46,17 @@ export const formatValue = } else if (numberFormat === NumberFormatEnum.Engineering) { strVal = value.toPrecision(maxPrecision); } else if (numberFormat === NumberFormatEnum.Hexadecimal) { - strVal = `0x${Math.round(value).toString(16)}`; + strVal = `0x${Math.floor(value).toString(16)}`; } else { - strVal = value.toFixed(maxPrecision); + const absValue = Math.abs(value); + if ( + (precision < 4 && absValue < Math.pow(10, precision)) || + absValue > 10000 + ) { + strVal = value.toPrecision(maxPrecision); + } else { + strVal = value.toPrecision(1 + Math.floor(Math.log10(absValue))); + } } return showUnits ? `${strVal} ${units}` : strVal; From d947b62f132d2742616410a8fd05cc9cb94cda58 Mon Sep 17 00:00:00 2001 From: Greg Harris Date: Tue, 30 Sep 2025 17:24:12 +0100 Subject: [PATCH 3/3] Improving sizing of widget and formatting of tick labels --- src/ui/widgets/Meter/meter.test.tsx | 57 +++++--- src/ui/widgets/Meter/meter.tsx | 147 ++++++++++---------- src/ui/widgets/Meter/meterUtilities.test.ts | 87 +++++++++--- src/ui/widgets/Meter/meterUtilities.ts | 65 +++++---- 4 files changed, 211 insertions(+), 145 deletions(-) diff --git a/src/ui/widgets/Meter/meter.test.tsx b/src/ui/widgets/Meter/meter.test.tsx index 45f00bf..2d87bf2 100644 --- a/src/ui/widgets/Meter/meter.test.tsx +++ b/src/ui/widgets/Meter/meter.test.tsx @@ -8,24 +8,21 @@ import * as meterUtilities from "./meterUtilities"; import { DType, Font } from "../../../types"; vi.mock("react-gauge-component", () => ({ - GaugeComponent: vi.fn( - ({ value, minValue, maxValue, pointer, arc, labels }) => ( + GaugeComponent: vi.fn(({ value, minValue, maxValue, labels, style }) => ( +
-
-
-
-
- ) - ) + data-testid="value-label" + data-hide={labels.valueLabel.hide} + data-font-family={labels.valueLabel.style.fontFamily} + >
+ + )) })); vi.mock("./meterUtilities", async () => { @@ -161,15 +158,33 @@ describe("MeterComponent", () => { it("scales width correctly based on height/width ratio", () => { render(); + const gauge = screen.getByTestId("gauge-component"); + expect(JSON.parse(gauge.getAttribute("data-style") ?? "{}").width).toBe( + 190 + ); // scaled width + }); + + it("uses 100% parent box width when width/height ratio is greater than 1.9", () => { + render(); + const box = screen.getByTestId("gauge-component").parentElement; - expect(box).toHaveStyle("width: 190px"); // 1.9 * height + expect(box).toHaveStyle("width: 100%"); + }); + + it("uses width when width/height ratio is less than 1.9", () => { + render(); + + const gauge = screen.getByTestId("gauge-component"); + expect(JSON.parse(gauge.getAttribute("data-style") ?? "{}").width).toBe( + 150 + ); // specified width }); - it("uses default width when width/height ratio is less than 1.9", () => { + it("uses 100% parent box width when width/height ratio is less than 1.9", () => { render(); const box = screen.getByTestId("gauge-component").parentElement; - expect(box).toHaveStyle("width: 150px"); // original width + expect(box).toHaveStyle("width: 100%"); }); it("applies font styles correctly", () => { @@ -181,7 +196,7 @@ describe("MeterComponent", () => { const valueLabel = screen.getByTestId("value-label"); - const labelStyle = valueLabel.getAttribute("data-fontFamily"); + const labelStyle = valueLabel.getAttribute("data-font-family"); expect(labelStyle).toBe("Arial"); }); }); diff --git a/src/ui/widgets/Meter/meter.tsx b/src/ui/widgets/Meter/meter.tsx index 698e8f8..001afb4 100644 --- a/src/ui/widgets/Meter/meter.tsx +++ b/src/ui/widgets/Meter/meter.tsx @@ -16,7 +16,8 @@ import { GaugeComponent } from "react-gauge-component"; import { buildSubArcs, convertInfAndNanToUndefined, - createIntervals, + createTickPositions, + formatTickLabels, formatValue, NumberFormatEnum } from "./meterUtilities"; @@ -90,6 +91,10 @@ export const MeterComponent = ( // For a semi semicircle height / width is 2, but allow extra height for some padding const scaledWidth = width / height > 1.9 ? 1.9 * height : width; + // Calculate the tick positions and the string labels + const tickPositions = createTickPositions(minimum, maximum); + const tickLabels = formatTickLabels(tickPositions); + return ( - - string }; + } => ({ + value: value, + valueConfig: { + formatTextValue: () => tickLabels[index] + } + }) ), - width: 0.03 - }} - labels={{ - valueLabel: { + defaultTickValueConfig: { style: { - fontFamily: font?.css().fontFamily, fill: foregroundColor.toString(), - textShadow: "none" - }, - formatTextValue: getFormattedValue, - matchColorWithArc: true, - hide: !showValue - }, - tickLabels: { - type: "inner", - ticks: createIntervals(minimum, maximum).map( - ( - x: number - ): { - value: number; - valueConfig: { formatTextValue: () => string }; - } => ({ - value: x, - valueConfig: { - formatTextValue: formatValue(x, 1, 2, "", false) - } - }) - ), - defaultTickValueConfig: { - style: { - fill: foregroundColor.toString(), - fontSize: `${scaledWidth * 0.04}px`, - textShadow: "none", - fontFamily: font?.css().fontFamily - } - }, - defaultTickLineConfig: { - color: foregroundColor.toString() + fontSize: `${scaledWidth * 0.04}px`, + textShadow: "none", + fontFamily: font?.css().fontFamily } + }, + defaultTickLineConfig: { + color: foregroundColor.toString() } - }} - /> - + } + }} + /> ); }; diff --git a/src/ui/widgets/Meter/meterUtilities.test.ts b/src/ui/widgets/Meter/meterUtilities.test.ts index 3ce4817..ce8593b 100644 --- a/src/ui/widgets/Meter/meterUtilities.test.ts +++ b/src/ui/widgets/Meter/meterUtilities.test.ts @@ -4,9 +4,30 @@ import { convertInfAndNanToUndefined, formatValue, buildSubArcs, - createIntervals + createTickPositions, + formatTickLabels } from "./meterUtilities"; +describe("formatTickLabels", () => { + it("Nicely formats small set of integers", () => { + const ticks = [1, 2, 3, 5, 6, 7]; + const expectedTickLabels = ["1.0", "2.0", "3.0", "5.0", "6.0", "7.0"]; + expect(formatTickLabels(ticks)).toStrictEqual(expectedTickLabels); + }); + + it("Only formats every other tick number for long number strings ", () => { + const ticks = [700, 800, 900, 1000, 1100, 1200]; + const expectedTickLabels = ["700", "", "900", "", "1100", ""]; + expect(formatTickLabels(ticks)).toStrictEqual(expectedTickLabels); + }); + + it("Only formats every other tick number for long negative number strings ", () => { + const ticks = [-120, -110, -100, -90, -80]; + const expectedTickLabels = ["-120", "", "-100", "", "-80"]; + expect(formatTickLabels(ticks)).toStrictEqual(expectedTickLabels); + }); +}); + describe("convertInfAndNanToUndefined", () => { it("should return the same value for finite numbers", () => { expect(convertInfAndNanToUndefined(42)).toBe(42); @@ -248,114 +269,136 @@ describe("buildSubArcs", () => { describe("createIntervals", () => { it("should create intervals between min and max values", () => { - const intervals = createIntervals(0, 100); + const intervals = createTickPositions(0, 100); expect(intervals.length).toBe(11); expect(intervals.length).toBeLessThanOrEqual(16); expect(intervals[0]).toBe(0); + expect(intervals[1]).toBe(10); expect(intervals[intervals.length - 1]).toBe(100); }); it("should create intervals for decimal ranges", () => { - const intervals = createIntervals(0.1, 0.9); - expect(intervals.length).toBe(9); + const intervals = createTickPositions(0.1, 0.9); + expect(intervals.length).toBe(10); expect(intervals.length).toBeLessThanOrEqual(16); expect(intervals[0]).toBe(0.1); + expect(intervals[1]).toBe(0.2); expect(intervals[intervals.length - 1]).toBe(0.9); }); it("should create intervals for negative ranges", () => { - const intervals = createIntervals(-100, -10); + const intervals = createTickPositions(-100, -10); expect(intervals.length).toBe(10); expect(intervals.length).toBeLessThanOrEqual(16); expect(intervals[0]).toBe(-100); + expect(intervals[1]).toBe(-90); expect(intervals[intervals.length - 1]).toBe(-10); }); - it("should create intervals for mixed ranges", () => { - const intervals = createIntervals(-50, 50); + it("should create intervals for mixed positive/negative limits", () => { + const intervals = createTickPositions(-50, 50); expect(intervals.length).toBe(11); expect(intervals.length).toBeLessThanOrEqual(16); expect(intervals[0]).toBe(-50); + expect(intervals[1]).toBe(-40); expect(intervals[intervals.length - 1]).toBe(50); }); it("should handle very small ranges", () => { - const intervals = createIntervals(1, 1.0001); + const intervals = createTickPositions(1, 1.0001); expect(intervals.length).toBe(11); expect(intervals[0]).toBe(1); + expect(intervals[1]).toBe(1.00001); expect(intervals[intervals.length - 1]).toBe(1.0001); }); it("should throw error when min is greater than or equal to max", () => { - expect(() => createIntervals(10, 5)).toThrow( + expect(() => createTickPositions(10, 5)).toThrow( "Minimum value must be less than maximum value" ); - expect(() => createIntervals(5, 5)).toThrow( + expect(() => createTickPositions(5, 5)).toThrow( "Minimum value must be less than maximum value" ); }); it("should handle very large ranges", () => { - const intervals = createIntervals(0, 1000000); + const intervals = createTickPositions(0, 1000000); expect(intervals.length).toBe(11); expect(intervals.length).toBeLessThanOrEqual(16); expect(intervals[0]).toBe(0); + expect(intervals[1]).toBe(100000); expect(intervals[intervals.length - 1]).toBe(1000000); }); - it("should handle very small exponential ranges", () => { - const intervals = createIntervals(1.0e-12, 1.0e9); + it("should handle very large exponential ranges", () => { + const intervals = createTickPositions(1.0e-12, 1.0e9); expect(intervals.length).toBe(11); expect(intervals[0]).toBe(0); // note non-log scale + expect(intervals[1]).toBe(1.0e8); expect(intervals[intervals.length - 1]).toBe(1.0e9); }); it("should create intervals of spacing 0.5, when the range is 6", () => { - const intervals = createIntervals(20, 26); + const intervals = createTickPositions(20, 26); expect(intervals.length).toBe(13); expect(intervals[0]).toBe(20); + expect(intervals[1]).toBe(20.5); expect(intervals[intervals.length - 1]).toBe(26); }); it("should create intervals of spacing 1, when the range is 7", () => { - const intervals = createIntervals(20, 27); + const intervals = createTickPositions(20, 27); expect(intervals.length).toBe(8); expect(intervals[0]).toBe(20); + expect(intervals[1]).toBe(21); expect(intervals[intervals.length - 1]).toBe(27); }); it("should create intervals of spacing 2 when the range is 16", () => { - const intervals = createIntervals(10, 26); + const intervals = createTickPositions(10, 26); expect(intervals.length).toBe(9); expect(intervals[0]).toBe(10); + expect(intervals[1]).toBe(12); expect(intervals[intervals.length - 1]).toBe(26); }); it("should create intervals of spacing 2.5 when the range is 25", () => { - const intervals = createIntervals(10, 35); + const intervals = createTickPositions(10, 35); expect(intervals.length).toBe(11); expect(intervals[0]).toBe(10); + expect(intervals[1]).toBe(12.5); expect(intervals[intervals.length - 1]).toBe(35); }); it("should create intervals of spacing 5 when the range is 45", () => { - const intervals = createIntervals(10, 55); + const intervals = createTickPositions(10, 55); expect(intervals.length).toBe(10); expect(intervals[0]).toBe(10); + expect(intervals[1]).toBe(15); expect(intervals[intervals.length - 1]).toBe(55); }); it("should create intervals of spacing 10 when the range is 90", () => { - const intervals = createIntervals(10, 100); + const intervals = createTickPositions(10, 100); expect(intervals.length).toBe(10); expect(intervals[0]).toBe(10); + expect(intervals[1]).toBe(20); expect(intervals[intervals.length - 1]).toBe(100); }); it("should create intervals of spacing 20 when the range is 160", () => { - const intervals = createIntervals(10, 170); - expect(intervals.length).toBe(10); + const intervals = createTickPositions(10, 190); + expect(intervals.length).toBe(11); expect(intervals[0]).toBe(10); - expect(intervals[intervals.length - 1]).toBe(170); + expect(intervals[1]).toBe(20); + expect(intervals[intervals.length - 1]).toBe(190); + }); + + it("should create intervals of when range is small fraction of the number magnitude", () => { + const intervals = createTickPositions(10.0121, 10.0129); + expect(intervals.length).toBe(10); + expect(intervals[0]).toBe(10.0121); + expect(intervals[1]).toBe(10.0122); + expect(intervals[intervals.length - 1]).toBe(10.0129); }); }); diff --git a/src/ui/widgets/Meter/meterUtilities.ts b/src/ui/widgets/Meter/meterUtilities.ts index 74cb5e3..90711a0 100644 --- a/src/ui/widgets/Meter/meterUtilities.ts +++ b/src/ui/widgets/Meter/meterUtilities.ts @@ -5,6 +5,26 @@ export enum NumberFormatEnum { Hexadecimal = 4 } +/** + * Given a series of numerical tick positions, returns an array of string tick positions. + * If the length of a string is greater than 4, returns every other value as a string + * @param tickPositions The numerical values of the tick locations + * @returns An array of tick positions as strings + */ +export const formatTickLabels = (tickPositions: number[]) => { + let tickLabels = tickPositions.map((value: number) => + formatValue(value, 1, 2, "", false)() + ); + + if (tickLabels.find((str: string) => str.length > 3)) { + // If any of the numerical stings are longer than 3 characters, show every other tick label. + tickLabels = tickLabels.map((value, index) => + index % 2 === 0 ? value : "" + ); + } + return tickLabels; +}; + /** * Convert inf and NaN Number values to undefined * @param value The value @@ -137,13 +157,13 @@ export const buildSubArcs = ( }; /** - * Creates interval boundaries between min and max values + * Calculates meter tick positions between min and max values * * @param min The minimum value of the range * @param max The maximum value of the range - * @returns An array of interval boundary values + * @returns An array of tick values */ -export const createIntervals = (min: number, max: number): number[] => { +export const createTickPositions = (min: number, max: number): number[] => { if (min >= max) { throw new Error("Minimum value must be less than maximum value"); } @@ -156,41 +176,30 @@ export const createIntervals = (min: number, max: number): number[] => { } // Determine the scale factor based on the range - let scale = 1; - let decimalPlaces = 1; + const decimalPlaces = Math.ceil(Math.abs(Math.log10(range))); - if (range < 1) { - // For decimal ranges, find how many decimal places we need - decimalPlaces = Math.ceil(Math.abs(Math.log10(range))); - scale = Math.pow(10, decimalPlaces); - } - - // Scale up the values to work with integers - const scaledMin = min * scale; - const scaledMax = max * scale; - const scaledRange = scaledMax - scaledMin; - - // Find the appropriate "nice" step size - const candidateSteps = findNiceStepSizes(scaledRange); + const candidateSteps = calculateCandidateStepSizes(range); let bestScore = Number.NEGATIVE_INFINITY; - let bestIntervals: number[] = []; + let bestStep = 1; for (const step of candidateSteps) { - const intervals = generateIntervals(scaledMin, scaledMax, step); - - const score = scoreIntervals(intervals.length); + const numberOfSteps = Math.floor(range / step) + 1; + const score = scoreStepSizes(numberOfSteps); if (score > bestScore) { bestScore = score; - bestIntervals = intervals; + bestStep = step; } } - return bestIntervals.map(val => Number((val / scale).toFixed(decimalPlaces))); + const bestTickPositions = generateIntervals(min, max, bestStep); + + // Scale back to the correct magnitude, round and return + return bestTickPositions.map(val => Number(val.toFixed(decimalPlaces))); }; -const findNiceStepSizes = (range: number): number[] => { +const calculateCandidateStepSizes = (range: number): number[] => { const targetIntervals = 10; const roughStepSize = range / targetIntervals; @@ -221,7 +230,6 @@ const generateIntervals = ( current += step; } - // Add points at step intervals while (current < max && intervals.length < 20) { intervals.push(current); current += step; @@ -234,17 +242,14 @@ const generateIntervals = ( return intervals; }; -const scoreIntervals = (count: number): number => { - // Ideal +const scoreStepSizes = (count: number): number => { if (count >= 8 && count <= 16) { return 100 - Math.abs(10 - count); } - // Acceptable if (count >= 5 && count <= 21) { return 90 - Math.abs(10 - count); } - // too many or too few intervals return 80 - Math.abs(10 - count); };