From c687f4ce5f2c5e1ce99eb7690e03eb340e72ceeb Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Fri, 12 Jun 2026 18:08:00 +0500 Subject: [PATCH 1/9] feat: add Auto-contrast toggle to Data labels for improved label legibility on gradient cells --- CHANGELOG.md | 1 + capabilities.json | 5 + package-lock.json | 842 +++++++++++++++++++++--- package.json | 2 + src/heatmapUtils.ts | 18 +- src/settings.ts | 15 +- src/visual.ts | 14 +- stringResources/en-US/resources.resjson | 1 + test/visualTest.ts | 152 ++++- 9 files changed, 944 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce5eba..b1fdab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### New features * Added "Invert Color Scale" toggle to reverse the color gradient direction * Diverging (three-stop) gradient: new "Add gradient middle" toggle and "Gradient middle" colour picker in the Format pane → General → Gradient Colors group. When enabled, the colour scale interpolates smoothly through the chosen midpoint colour (default: `#767676`). The midpoint uses this default until the user explicitly changes it in the Format pane. +* Added Auto-contrast toggle to Data labels: when enabled, each label's lightness is automatically clamped to remain legible against its cell background colour while preserving the user-picked hue and saturation. ### Bug fixes * Fixed "Invert Color Scale" and gradient middle colour not being neutralized in high-contrast mode; both features are now automatically disabled when the Power BI high-contrast theme is active to preserve accessibility contrast requirements. diff --git a/capabilities.json b/capabilities.json index a438564..4d1b789 100644 --- a/capabilities.json +++ b/capabilities.json @@ -316,6 +316,11 @@ "type": { "bool": true } + }, + "autoContrast": { + "type": { + "bool": true + } } } }, diff --git a/package-lock.json b/package-lock.json index 556ee17..a102bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.1.0.0", "dependencies": { "d3-array": "^3.2.4", + "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-transition": "^3.0.1", @@ -26,6 +27,7 @@ }, "devDependencies": { "@types/d3-array": "^3.2.1", + "@types/d3-color": "^3.1.0", "@types/d3-scale": "^4.0.9", "@types/d3-selection": "^3.0.11", "@types/d3-transition": "^3.0.9", @@ -539,6 +541,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1210,6 +1225,446 @@ "dev": true, "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", @@ -1544,6 +1999,13 @@ "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", "dev": true }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -2719,13 +3181,13 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", - "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.6.tgz", + "integrity": "sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==", "dev": true, "license": "ISC", "dependencies": { - "bn.js": "^5.2.2", + "bn.js": "^5.2.3", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", @@ -3131,12 +3593,13 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/compare-versions": { @@ -3512,6 +3975,7 @@ "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" } @@ -3939,20 +4403,22 @@ } }, "node_modules/engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", "dev": true, + "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" + "ws": "~8.20.1" }, "engines": { "node": ">=10.2.0" @@ -3967,6 +4433,31 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", @@ -4123,15 +4614,14 @@ } }, "node_modules/eslint-plugin-powerbi-visuals": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-powerbi-visuals/-/eslint-plugin-powerbi-visuals-1.1.0.tgz", - "integrity": "sha512-bLKX3Z8W72JWGuAvhPfwn0O8Io6Se5zq2q7+H9wTeosn0IPx5dAFvT25oR4knWyqAKwIQNPXg0M2QqPtuPjGCg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-powerbi-visuals/-/eslint-plugin-powerbi-visuals-1.1.1.tgz", + "integrity": "sha512-oWmq/YQHJGsUvMVY/Si3cRM9MkoDJxBMCXc0zpTOuhYXmYwiWzSYy5JeFZLtooVJ1FEvKVI26eXbTaPEnT2m1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/parser": "^8.45.0", - "globals": "^16.4.0", - "path": "0.12.7" + "@typescript-eslint/parser": "^8.57.2", + "globals": "^17.4.0" } }, "node_modules/eslint-plugin-powerbi-visuals/node_modules/@typescript-eslint/parser": { @@ -4292,9 +4782,9 @@ } }, "node_modules/eslint-plugin-powerbi-visuals/node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -4492,6 +4982,29 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -4550,6 +5063,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5183,6 +5715,16 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -5475,6 +6017,16 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -5677,6 +6229,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5950,6 +6509,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5996,6 +6565,13 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7428,17 +8004,6 @@ "node": ">= 0.8" } }, - "node_modules/path": { - "version": "0.12.7", - "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", - "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "process": "^0.11.1", - "util": "^0.10.3" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -7552,6 +8117,16 @@ "node": ">=6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -7771,37 +8346,39 @@ } }, "node_modules/powerbi-visuals-tools": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/powerbi-visuals-tools/-/powerbi-visuals-tools-7.0.3.tgz", - "integrity": "sha512-E3RhQAJgcBSelYEVk/nQl8Qk/CRx2mne2jVmOMrTZomrodsrVIc66cc5b1gLtLGIkXKkuXtOO/vA1IRNt+XloA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/powerbi-visuals-tools/-/powerbi-visuals-tools-7.1.0.tgz", + "integrity": "sha512-U9XmM2OjZCKG7nx/82VfFjQd1vw5Tnlsi9dlkFh3UJn4TsECFrMDDMDq4AlwrlWmzdBCrJrffmrgs5vDiSsRXg==", "dev": true, "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.3", "@typescript-eslint/parser": "^8.50.0", "async": "^3.2.6", "chalk": "^5.4.1", - "commander": "^13.1.0", + "commander": "^14.0.3", "compare-versions": "^6.1.1", "css-loader": "^7.1.4", - "eslint-plugin-powerbi-visuals": "1.1.0", - "fs-extra": "^11.3.3", + "eslint-plugin-powerbi-visuals": "1.1.1", + "fs-extra": "^11.3.4", "inline-source-map": "^0.6.3", "json-loader": "0.5.7", "jszip": "^3.10.1", - "less": "^4.5.1", - "less-loader": "^12.3.1", + "less": "^4.6.4", + "less-loader": "^12.3.2", "lodash.clonedeep": "4.5.0", "lodash.defaults": "4.2.0", "lodash.isequal": "4.5.0", "lodash.ismatch": "^4.4.0", - "mini-css-extract-plugin": "^2.10.0", - "powerbi-visuals-webpack-plugin": "^5.0.0", - "terser-webpack-plugin": "^5.3.16", + "mini-css-extract-plugin": "^2.10.2", + "powerbi-visuals-webpack-plugin": "^5.0.1", + "terser-webpack-plugin": "^5.4.0", "ts-loader": "^9.5.4", "typescript": "^5.9.3", - "webpack": "^5.105.3", + "webpack": "^5.105.4", "webpack-bundle-analyzer": "4.10.2", - "webpack-dev-server": "^5.2.3" + "webpack-dev-server": "^5.2.3", + "zod": "^4.3.6" }, "bin": { "pbiviz": "bin/pbiviz.js" @@ -8280,9 +8857,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8566,6 +9143,59 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -8933,9 +9563,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -9062,15 +9692,41 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "~4.3.4", - "ws": "~8.17.1" + "debug": "~4.4.1", + "ws": "~8.20.1" } }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", @@ -9615,9 +10271,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -9983,29 +10639,12 @@ "dev": true, "license": "MIT" }, - "node_modules/util": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", - "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "2.0.3" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "node_modules/util/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true, - "license": "ISC" - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -10424,28 +11063,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/webpack-merge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", @@ -10643,10 +11260,11 @@ "dev": true }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -10759,6 +11377,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 87b4e34..48c0d3c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "private": true, "dependencies": { "d3-array": "^3.2.4", + "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-transition": "^3.0.1", @@ -25,6 +26,7 @@ }, "devDependencies": { "@types/d3-array": "^3.2.1", + "@types/d3-color": "^3.1.0", "@types/d3-scale": "^4.0.9", "@types/d3-selection": "^3.0.11", "@types/d3-transition": "^3.0.9", diff --git a/src/heatmapUtils.ts b/src/heatmapUtils.ts index 3182c5d..4fdafaa 100644 --- a/src/heatmapUtils.ts +++ b/src/heatmapUtils.ts @@ -32,6 +32,8 @@ import { ColorHelper } from "powerbi-visuals-utils-colorutils"; import maxBy from "lodash.maxby"; +import { color as d3Color, hsl as d3Hsl, lab as d3Lab } from "d3-color"; + import { IColorArray, TableHeatMapChartData } from "./dataInterfaces"; import { BaseLabelCardSettings, colorbrewer, SettingsModel, YAxisLabelsSettings } from "./settings"; @@ -181,4 +183,18 @@ export function parseSettings(colorHelper: ColorHelper, settingsModel: SettingsM } return settingsModel; -} \ No newline at end of file +} + +// Preserve the user's hue/saturation; clamp lightness to stay legible on `backgroundColor`. +export function getAdaptiveLabelColor(userColor: string, backgroundColor: string): string { + const bg = d3Color(backgroundColor); + const fg = d3Hsl(userColor); + // Invalid/unsupported inputs -> keep the user-picked color unchanged. + if (bg === null || fg === null || isNaN(fg.l)) { + return userColor; + } + // lab(...).l is perceptual lightness in [0, 100]; high = light background. + fg.l = d3Lab(bg).l > 60 ? 0.2 : 0.85; + return fg.formatHex(); +} + diff --git a/src/settings.ts b/src/settings.ts index 031ad0b..4c89cc0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -556,6 +556,19 @@ export class BaseLabelCardSettings extends FormattingSettingsSimpleCard { } } +export class DataLabelsCardSettings extends BaseLabelCardSettings { + public autoContrast = new formattingSettings.ToggleSwitch({ + name: "autoContrast", + displayNameKey: "Visual_LabelsAutoContrast", + value: true + }); + + constructor(name: string, displayNameKey: string, isShown: boolean = true) { + super(name, displayNameKey, isShown); + this.slices = [this.font, this.fill, this.autoContrast]; + } +} + export class YAxisLabelsSettings extends BaseLabelCardSettings { private static TextSymbolMinValue: number = 0; private static TextSymbolMaxValue: number = 50; @@ -581,7 +594,7 @@ export class YAxisLabelsSettings extends BaseLabelCardSettings { } export class SettingsModel extends FormattingSettingsModel { - public labels: BaseLabelCardSettings = new BaseLabelCardSettings("labels", "Visual_DataLabels", false); + public labels: DataLabelsCardSettings = new DataLabelsCardSettings("labels", "Visual_DataLabels", false); public xAxisLabels: BaseLabelCardSettings = new BaseLabelCardSettings("xAxisLabels", "Visual_XAxis"); public yAxisLabels: YAxisLabelsSettings = new YAxisLabelsSettings("yAxisLabels", "Visual_YAxis"); public general: GeneralSettings = new GeneralSettings(); diff --git a/src/visual.ts b/src/visual.ts index dc9ba70..c6adce8 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -75,6 +75,7 @@ import { import { BaseLabelCardSettings, + DataLabelsCardSettings, SettingsModel, colorbrewer } from "./settings"; @@ -83,6 +84,7 @@ import { calculateGridSizeHeight, calculateGridSizeWidth, CellMaxHeightLimit, + getAdaptiveLabelColor, getXAxisHeight, getYAxisHeight, getYAxisWidth, @@ -616,7 +618,17 @@ export class TableHeatMap implements IVisual { }) .style("text-anchor", TableHeatMap.ConstMiddle) .call(this.applyFontStylesToLabels(labelSettings)) - .style("fill", labelSettings.fill.value.value) + .style("fill", (dataPoint: TableHeatMapDataPoint) => { + const userColor: string = labelSettings.fill.value.value; + if (!(labelSettings as DataLabelsCardSettings).autoContrast?.value) { + return userColor; + } + const backgroundColor: string = renderOptions.colorScale(dataPoint.value); + if (!backgroundColor) { + return userColor; + } + return getAdaptiveLabelColor(userColor, backgroundColor); + }) .text((dataPoint: TableHeatMapDataPoint) => { let textValue: string = valueFormatter.format(dataPoint.value); textProperties.text = textValue; diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index 1358c78..336374e 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -17,6 +17,7 @@ "Visual_GradientStart": "Gradient start", "Visual_InvertColorScale": "Invert color scale", "Visual_LabelsFill": "Color", + "Visual_LabelsAutoContrast": "Auto-contrast", "Visual_Long_Description": "Use this custom visual to build a table heat map that can be used to visualise and compare data values in an easy and intuitive way.\nYou have a built-in option within this visual to specify the number of buckets used for splitting your data.\nAdditionally, you can also customise it by choosing a colour scheme in line with your brand colours", "Visual_MaxTextSymbols": "Max text symbols", "Visual_MaxValue": "Max value", diff --git a/test/visualTest.ts b/test/visualTest.ts index 416da35..fd3b427 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -50,7 +50,8 @@ import { calculateGridSizeHeight, calculateGridSizeWidth, ConstGridMinHeight, CellMaxHeightLimit, ConstGridMinWidth, CellMaxWidthFactorLimit, getYAxisWidth, getXAxisHeight, getYAxisHeight, - parseSettings + parseSettings, + getAdaptiveLabelColor } from "../src/heatmapUtils"; const DefaultTimeout: number = 300; @@ -1118,5 +1119,154 @@ describe("TableHeatmap", () => { }); }); }); + + describe("Data labels auto-contrast", () => { + describe("utils:getAdaptiveLabelColor", () => { + it("returns a darker color for a light background", () => { + const result = getAdaptiveLabelColor("#ff0000", "#ffffff"); + const { R, G, B } = parseColorString(result); + // Lab lightness of #ffffff ≈ 100 > 60 → clamp HSL l to 0.2 → dark label + expect(R + G + B).toBeLessThan(3 * 128); + }); + + it("returns a lighter color for a dark background", () => { + const result = getAdaptiveLabelColor("#ff0000", "#000000"); + const { R, G, B } = parseColorString(result); + // Lab lightness of #000000 ≈ 0 ≤ 60 → clamp HSL l to 0.85 → light label + expect(R + G + B).toBeGreaterThan(3 * 128); + }); + + it("preserves the red hue of the user-picked color on a light background", () => { + const { R, G, B } = parseColorString(getAdaptiveLabelColor("#ff0000", "#ffffff")); + expect(R).toBeGreaterThan(G); + expect(R).toBeGreaterThan(B); + }); + + it("preserves the red hue of the user-picked color on a dark background", () => { + const { R, G, B } = parseColorString(getAdaptiveLabelColor("#ff0000", "#000000")); + expect(R).toBeGreaterThan(G); + expect(R).toBeGreaterThan(B); + }); + + it("returns the userColor unchanged when backgroundColor is invalid", () => { + expect(getAdaptiveLabelColor("#ff0000", "not-a-color")).toBe("#ff0000"); + }); + + it("returns the userColor unchanged when userColor is invalid", () => { + expect(getAdaptiveLabelColor("not-a-color", "#ffffff")).toBe("not-a-color"); + }); + }); + + describe("toggle: autoContrast OFF", () => { + it("all labels use the exact user-picked fill color", (done) => { + const userColor = "#ff6600"; + dataView.metadata.objects = { + labels: { + show: true, + fill: { solid: { color: userColor } }, + autoContrast: false + } + }; + + visualBuilder.updateRenderTimeout(dataView, () => { + const labels = Array.from(document.querySelectorAll(".heatMapDataLabels")); + expect(labels.length).toBeGreaterThan(0); + const allMatch = labels.every(el => + areColorsEqual(getComputedStyle(el)["fill"], userColor) + ); + expect(allMatch).toBeTrue(); + done(); + }, DefaultTimeout); + }); + }); + + describe("toggle: autoContrast ON", () => { + it("at least one label fill differs from the static user-picked color", (done) => { + const userColor = "#888888"; + dataView.metadata.objects = { + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: false } + }; + + visualBuilder.updateRenderTimeout(dataView, () => { + const staticFills = Array.from(document.querySelectorAll(".heatMapDataLabels")) + .map(el => getComputedStyle(el)["fill"]); + + dataView.metadata.objects = { + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: true } + }; + + visualBuilder.updateRenderTimeout(dataView, () => { + const adaptedFills = Array.from(document.querySelectorAll(".heatMapDataLabels")) + .map(el => getComputedStyle(el)["fill"]); + const changedCount = staticFills.filter((fill, i) => + !areColorsEqual(fill, adaptedFills[i]) + ).length; + expect(changedCount).toBeGreaterThan(0); + done(); + }, DefaultTimeout); + }, DefaultTimeout); + }); + + it("no label has an empty or transparent fill", (done) => { + dataView.metadata.objects = { + labels: { show: true, autoContrast: true } + }; + + visualBuilder.updateRenderTimeout(dataView, () => { + const labels = Array.from(document.querySelectorAll(".heatMapDataLabels")); + expect(labels.length).toBeGreaterThan(0); + const hasEmptyFill = labels.some(el => { + const fill = getComputedStyle(el)["fill"]; + return !fill || fill === "none" || fill === "transparent"; + }); + expect(hasEmptyFill).toBeFalse(); + done(); + }, DefaultTimeout); + }); + + it("toggling back OFF restores the user-picked fill color on all labels", (done) => { + const userColor = "#3399ff"; + dataView.metadata.objects = { + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: true } + }; + + visualBuilder.updateRenderTimeout(dataView, () => { + dataView.metadata.objects = { + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: false } + }; + + visualBuilder.updateRenderTimeout(dataView, () => { + const labels = Array.from(document.querySelectorAll(".heatMapDataLabels")); + expect(labels.length).toBeGreaterThan(0); + const allMatch = labels.every(el => + areColorsEqual(getComputedStyle(el)["fill"], userColor) + ); + expect(allMatch).toBeTrue(); + done(); + }, DefaultTimeout); + }, DefaultTimeout); + }); + + it("null-value cell label falls back to user color without an empty fill", (done) => { + const userColor = "#ff0000"; + dataView.metadata.objects = { + general: { fillNullValuesCells: true }, + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: true } + }; + dataView.categorical!.values![0].values![0] = null; + + visualBuilder.updateRenderTimeout(dataView, () => { + const labels = Array.from(document.querySelectorAll(".heatMapDataLabels")); + expect(labels.length).toBeGreaterThan(0); + const hasEmptyFill = labels.some(el => { + const fill = getComputedStyle(el)["fill"]; + return !fill || fill === "none"; + }); + expect(hasEmptyFill).toBeFalse(); + done(); + }, DefaultTimeout); + }); + }); + }); }); From a00097ed0bbee30f92c2c9b22a312fde81d51fb0 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Mon, 15 Jun 2026 10:28:49 +0500 Subject: [PATCH 2/9] refactor: standardize constant naming for opacity and color in heatmap utilities --- src/heatmapUtils.ts | 15 +++++++++------ src/visual.ts | 4 ++-- test/visualTest.ts | 14 +++++++------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/heatmapUtils.ts b/src/heatmapUtils.ts index 4fdafaa..511e24f 100644 --- a/src/heatmapUtils.ts +++ b/src/heatmapUtils.ts @@ -37,9 +37,12 @@ import { color as d3Color, hsl as d3Hsl, lab as d3Lab } from "d3-color"; import { IColorArray, TableHeatMapChartData } from "./dataInterfaces"; import { BaseLabelCardSettings, colorbrewer, SettingsModel, YAxisLabelsSettings } from "./settings"; -export const DimmedOpacity: number = 0.4; -export const DefaultOpacity: number = 1.0; -export const DimmedColor: string = "black"; +export const DIMMED_OPACITY: number = 0.4; +export const DEFAULT_OPACITY: number = 1.0; +export const DIMMED_COLOR: string = "black"; +export const LAB_LIGHT_BG_THRESHOLD: number = 60; +export const DARK_LABEL_LIGHTNESS: number = 0.2; +export const LIGHT_LABEL_LIGHTNESS: number = 0.85; export function getOpacity( selected: boolean, @@ -48,10 +51,10 @@ export function getOpacity( hasPartialHighlights: boolean): number { if ((hasPartialHighlights && !highlight) || (hasSelection && !selected)) { - return DimmedOpacity; + return DIMMED_OPACITY; } - return DefaultOpacity; + return DEFAULT_OPACITY; } export const YAxisAdditionalMargin: number = 5; @@ -194,7 +197,7 @@ export function getAdaptiveLabelColor(userColor: string, backgroundColor: string return userColor; } // lab(...).l is perceptual lightness in [0, 100]; high = light background. - fg.l = d3Lab(bg).l > 60 ? 0.2 : 0.85; + fg.l = d3Lab(bg).l > LAB_LIGHT_BG_THRESHOLD ? DARK_LABEL_LIGHTNESS : LIGHT_LABEL_LIGHTNESS; return fg.formatHex(); } diff --git a/src/visual.ts b/src/visual.ts index c6adce8..493dc93 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -590,7 +590,7 @@ export class TableHeatMap implements IVisual { private renderLabels(renderOptions: IRenderOptions): Selection { const { chartData, settingsModel, xOffset, yOffset, gridSizeHeight, gridSizeWidth } = renderOptions; - const labelSettings: BaseLabelCardSettings = settingsModel.labels; + const labelSettings: DataLabelsCardSettings = settingsModel.labels; const maxDataText = chartData.dataPoints.reduce((max: string, dp: TableHeatMapDataPoint) => { const val = dp.valueStr || ""; @@ -620,7 +620,7 @@ export class TableHeatMap implements IVisual { .call(this.applyFontStylesToLabels(labelSettings)) .style("fill", (dataPoint: TableHeatMapDataPoint) => { const userColor: string = labelSettings.fill.value.value; - if (!(labelSettings as DataLabelsCardSettings).autoContrast?.value) { + if (!labelSettings.autoContrast?.value) { return userColor; } const backgroundColor: string = renderOptions.colorScale(dataPoint.value); diff --git a/test/visualTest.ts b/test/visualTest.ts index fd3b427..da81f37 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -45,7 +45,7 @@ import { ColorHelper } from "powerbi-visuals-utils-colorutils"; import { TableHeatMapChartData } from "../src/dataInterfaces"; import { colorbrewer, SettingsModel } from "../src/settings"; import { - getOpacity, DimmedOpacity, DefaultOpacity, DimmedColor, + getOpacity, DIMMED_OPACITY, DEFAULT_OPACITY, DIMMED_COLOR, isDataViewValid, textLimit, calculateGridSizeHeight, calculateGridSizeWidth, ConstGridMinHeight, CellMaxHeightLimit, ConstGridMinWidth, CellMaxWidthFactorLimit, @@ -952,23 +952,23 @@ describe("TableHeatmap", () => { describe("utils:getOpacity", () => { it("returns DefaultOpacity when no selection or highlights are active", () => { - expect(getOpacity(false, false, false, false)).toBe(DefaultOpacity); + expect(getOpacity(false, false, false, false)).toBe(DEFAULT_OPACITY); }); it("returns DefaultOpacity for a selected element when selection is active", () => { - expect(getOpacity(true, false, true, false)).toBe(DefaultOpacity); + expect(getOpacity(true, false, true, false)).toBe(DEFAULT_OPACITY); }); it("returns DimmedOpacity for an unselected element when selection is active", () => { - expect(getOpacity(false, false, true, false)).toBe(DimmedOpacity); + expect(getOpacity(false, false, true, false)).toBe(DIMMED_OPACITY); }); it("returns DefaultOpacity for a highlighted element when partial highlights are active", () => { - expect(getOpacity(false, true, false, true)).toBe(DefaultOpacity); + expect(getOpacity(false, true, false, true)).toBe(DEFAULT_OPACITY); }); it("returns DimmedOpacity for a non-highlighted element when partial highlights are active", () => { - expect(getOpacity(false, false, false, true)).toBe(DimmedOpacity); + expect(getOpacity(false, false, false, true)).toBe(DIMMED_OPACITY); }); }); @@ -1115,7 +1115,7 @@ describe("TableHeatmap", () => { describe("DimmedColor", () => { it("is 'black'", () => { - expect(DimmedColor).toBe("black"); + expect(DIMMED_COLOR).toBe("black"); }); }); }); From 9ed91b85d652cbd38accfde1940154a8fa4af5f9 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Mon, 15 Jun 2026 10:50:51 +0500 Subject: [PATCH 3/9] fix: update related tests for color validation --- src/dataInterfaces.ts | 1 + src/visual.ts | 2 +- test/visualTest.ts | 7 +++---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/dataInterfaces.ts b/src/dataInterfaces.ts index 1197246..6641eb4 100644 --- a/src/dataInterfaces.ts +++ b/src/dataInterfaces.ts @@ -39,6 +39,7 @@ type Quantile = ScaleQuantile; import IValueFormatter = valueFormatter.IValueFormatter; import { SettingsModel } from "./settings"; + export interface TableHeatMapDataPoint extends ISelectableDataPoint, TooltipEnabledDataPoint { categoryX: powerbi.PrimitiveValue; categoryY: powerbi.PrimitiveValue; diff --git a/src/visual.ts b/src/visual.ts index 493dc93..1ebb9f8 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -623,7 +623,7 @@ export class TableHeatMap implements IVisual { if (!labelSettings.autoContrast?.value) { return userColor; } - const backgroundColor: string = renderOptions.colorScale(dataPoint.value); + const backgroundColor: string = renderOptions.colorScale(dataPoint.value as number); if (!backgroundColor) { return userColor; } diff --git a/test/visualTest.ts b/test/visualTest.ts index da81f37..a73b55d 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -1198,10 +1198,9 @@ describe("TableHeatmap", () => { visualBuilder.updateRenderTimeout(dataView, () => { const adaptedFills = Array.from(document.querySelectorAll(".heatMapDataLabels")) .map(el => getComputedStyle(el)["fill"]); - const changedCount = staticFills.filter((fill, i) => - !areColorsEqual(fill, adaptedFills[i]) - ).length; - expect(changedCount).toBeGreaterThan(0); + const staticSet = new Set(staticFills.map(colorKey)); + const newColors = adaptedFills.map(colorKey).filter(k => !staticSet.has(k)); + expect(newColors.length).toBeGreaterThan(0); done(); }, DefaultTimeout); }, DefaultTimeout); From f8f20f2ad68a4e86fe1bf67e18d2662a70c2863a Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Mon, 15 Jun 2026 11:53:44 +0500 Subject: [PATCH 4/9] fix: enhance auto-contrast logic for data labels --- src/visual.ts | 6 +++++- test/visualTest.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/visual.ts b/src/visual.ts index 1ebb9f8..424ff80 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -623,7 +623,11 @@ export class TableHeatMap implements IVisual { if (!labelSettings.autoContrast?.value) { return userColor; } - const backgroundColor: string = renderOptions.colorScale(dataPoint.value as number); + const value = dataPoint.value; + if (typeof value !== "number" || !isFinite(value)) { + return userColor; + } + const backgroundColor: string = renderOptions.colorScale(value); if (!backgroundColor) { return userColor; } diff --git a/test/visualTest.ts b/test/visualTest.ts index a73b55d..e742758 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -43,7 +43,7 @@ import { TableHeatMap } from "../src/visual"; import { ClickEventType, createColorPalette, d3Click, parseColorString, renderTimeout } from "powerbi-visuals-utils-testutils"; import { ColorHelper } from "powerbi-visuals-utils-colorutils"; import { TableHeatMapChartData } from "../src/dataInterfaces"; -import { colorbrewer, SettingsModel } from "../src/settings"; +import { SettingsModel } from "../src/settings"; import { getOpacity, DIMMED_OPACITY, DEFAULT_OPACITY, DIMMED_COLOR, isDataViewValid, textLimit, @@ -1180,7 +1180,7 @@ describe("TableHeatmap", () => { }); }); - describe("toggle: autoContrast ON", () => { + describe("toggle: autoContrast OFF → ON", () => { it("at least one label fill differs from the static user-picked color", (done) => { const userColor = "#888888"; dataView.metadata.objects = { @@ -1216,7 +1216,8 @@ describe("TableHeatmap", () => { expect(labels.length).toBeGreaterThan(0); const hasEmptyFill = labels.some(el => { const fill = getComputedStyle(el)["fill"]; - return !fill || fill === "none" || fill === "transparent"; + if (!fill || fill === "none" || fill === "transparent") return true; + return /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(fill); }); expect(hasEmptyFill).toBeFalse(); done(); @@ -1259,7 +1260,8 @@ describe("TableHeatmap", () => { expect(labels.length).toBeGreaterThan(0); const hasEmptyFill = labels.some(el => { const fill = getComputedStyle(el)["fill"]; - return !fill || fill === "none"; + if (!fill || fill === "none" || fill === "transparent") return true; + return /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(fill); }); expect(hasEmptyFill).toBeFalse(); done(); From b8bb88380b84c5ffcdfb847e28c479178f6893bf Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Tue, 16 Jun 2026 09:59:29 +0500 Subject: [PATCH 5/9] fix(settings): set default value of autoContrast to false --- src/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/settings.ts b/src/settings.ts index 8ecebf8..5d3f22c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -566,7 +566,7 @@ export class DataLabelsCardSettings extends BaseLabelCardSettings { public autoContrast = new formattingSettings.ToggleSwitch({ name: "autoContrast", displayNameKey: "Visual_LabelsAutoContrast", - value: true + value: false }); constructor(name: string, displayNameKey: string, isShown: boolean = true) { From dc61c06720cc960bd12fe911fdbf9e3dfd67463d Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Tue, 16 Jun 2026 09:59:40 +0500 Subject: [PATCH 6/9] feat: enhance adaptive label color logic to preserve user-specified alpha and cache results for performance --- src/heatmapUtils.ts | 12 ++++++++++-- src/visual.ts | 13 ++++++++++++- test/visualTest.ts | 7 +++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/heatmapUtils.ts b/src/heatmapUtils.ts index ae6075c..e9db136 100644 --- a/src/heatmapUtils.ts +++ b/src/heatmapUtils.ts @@ -163,7 +163,14 @@ export function parseSettings(colorHelper: ColorHelper, settingsModel: SettingsM return settingsModel; } -// Preserve the user's hue/saturation; clamp lightness to stay legible on `backgroundColor`. +/** + * Preserves the user's hue/saturation and alpha; clamps only lightness to stay legible on `backgroundColor`. + * + * Note: uses a fixed Lab-lightness threshold (LAB_LIGHT_BG_THRESHOLD) rather than a full WCAG + * luminance-contrast calculation. For highly saturated hues (e.g. yellow on white) the result + * may not meet WCAG AA contrast requirements; the trade-off is intentional — hue and saturation + * are preserved so the user's brand colour identity is retained. + */ export function getAdaptiveLabelColor(userColor: string, backgroundColor: string): string { const bg = d3Color(backgroundColor); const fg = d3Hsl(userColor); @@ -173,6 +180,7 @@ export function getAdaptiveLabelColor(userColor: string, backgroundColor: string } // lab(...).l is perceptual lightness in [0, 100]; high = light background. fg.l = d3Lab(bg).l > LAB_LIGHT_BG_THRESHOLD ? DARK_LABEL_LIGHTNESS : LIGHT_LABEL_LIGHTNESS; - return fg.formatHex(); + // formatRgb() emits rgba(r,g,b,a) when opacity < 1, preserving any user-set transparency. + return fg.formatRgb(); } diff --git a/src/visual.ts b/src/visual.ts index 34cf667..b2f3c02 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -594,6 +594,10 @@ export class TableHeatMap implements IVisual { const textRect: SVGRect = textMeasurementService.measureSvgTextRect(textProperties); + // Cache adapted colors by (userColor|backgroundColor) key; avoids redundant Lab/HSL + // conversions for the same background bucket on every label within a single render pass. + const adaptiveLabelColorCache = new Map(); + const heatMapDataLables: Selection = this.mainGraphics .selectAll(TableHeatMap.ClsHeatMapDataLabels.selectorName) .data(chartData.dataPoints) @@ -620,7 +624,14 @@ export class TableHeatMap implements IVisual { if (!backgroundColor) { return userColor; } - return getAdaptiveLabelColor(userColor, backgroundColor); + const cacheKey = `${userColor}|${backgroundColor}`; + const cached = adaptiveLabelColorCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + const adapted = getAdaptiveLabelColor(userColor, backgroundColor); + adaptiveLabelColorCache.set(cacheKey, adapted); + return adapted; }) .text((dataPoint: TableHeatMapDataPoint) => { let textValue: string = valueFormatter.format(dataPoint.value); diff --git a/test/visualTest.ts b/test/visualTest.ts index 99787b6..d7a20e8 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -1248,6 +1248,13 @@ describe("TableHeatmap", () => { it("returns the userColor unchanged when userColor is invalid", () => { expect(getAdaptiveLabelColor("not-a-color", "#ffffff")).toBe("not-a-color"); }); + + it("preserves user-specified alpha/opacity in the output color", () => { + // Semi-transparent red on a light background — lightness is clamped but alpha must survive. + const result = getAdaptiveLabelColor("rgba(136, 0, 0, 0.5)", "#ffffff"); + // formatRgb() emits rgba(r, g, b, a) when opacity < 1 + expect(result).toMatch(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\.5\s*\)/); + }); }); describe("toggle: autoContrast OFF", () => { From 677af9ef477db5680015cb25edb538a6f9ee8ead Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Fri, 19 Jun 2026 15:55:57 +0500 Subject: [PATCH 7/9] feat: implements WCAG AA (4.5:1) contrast ratio features and auto-contrast modes --- capabilities.json | 6 +- package-lock.json | 839 +++--------------------- src/heatmapUtils.ts | 128 +++- src/settings.ts | 13 +- src/visual.ts | 13 +- stringResources/en-US/resources.resjson | 3 + test/visualTest.ts | 73 ++- 7 files changed, 322 insertions(+), 753 deletions(-) diff --git a/capabilities.json b/capabilities.json index 4d1b789..ee5dc48 100644 --- a/capabilities.json +++ b/capabilities.json @@ -319,7 +319,11 @@ }, "autoContrast": { "type": { - "bool": true + "enumeration": [ + { "value": "Off", "displayNameKey": "Visual_LabelsAutoContrast_Off" }, + { "value": "Soft", "displayNameKey": "Visual_LabelsAutoContrast_Soft" }, + { "value": "Strong", "displayNameKey": "Visual_LabelsAutoContrast_Strong" } + ] } } } diff --git a/package-lock.json b/package-lock.json index a102bab..595d369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -541,19 +541,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1225,446 +1212,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/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/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@noble/hashes": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", @@ -2000,9 +1547,9 @@ "dev": true }, "node_modules/@types/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "dev": true, "license": "MIT" }, @@ -3181,13 +2728,13 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.6.tgz", - "integrity": "sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.5.tgz", + "integrity": "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==", "dev": true, "license": "ISC", "dependencies": { - "bn.js": "^5.2.3", + "bn.js": "^5.2.2", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", @@ -3593,13 +3140,12 @@ } }, "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, - "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/compare-versions": { @@ -3975,7 +3521,6 @@ "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" } @@ -4403,22 +3948,20 @@ } }, "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", "dev": true, - "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", - "debug": "~4.4.1", + "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" @@ -4433,31 +3976,6 @@ "node": ">=10.0.0" } }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.21.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", @@ -4614,14 +4132,15 @@ } }, "node_modules/eslint-plugin-powerbi-visuals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-powerbi-visuals/-/eslint-plugin-powerbi-visuals-1.1.1.tgz", - "integrity": "sha512-oWmq/YQHJGsUvMVY/Si3cRM9MkoDJxBMCXc0zpTOuhYXmYwiWzSYy5JeFZLtooVJ1FEvKVI26eXbTaPEnT2m1A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-powerbi-visuals/-/eslint-plugin-powerbi-visuals-1.1.0.tgz", + "integrity": "sha512-bLKX3Z8W72JWGuAvhPfwn0O8Io6Se5zq2q7+H9wTeosn0IPx5dAFvT25oR4knWyqAKwIQNPXg0M2QqPtuPjGCg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/parser": "^8.57.2", - "globals": "^17.4.0" + "@typescript-eslint/parser": "^8.45.0", + "globals": "^16.4.0", + "path": "0.12.7" } }, "node_modules/eslint-plugin-powerbi-visuals/node_modules/@typescript-eslint/parser": { @@ -4782,9 +4301,9 @@ } }, "node_modules/eslint-plugin-powerbi-visuals/node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -4982,29 +4501,6 @@ "node": ">=0.8.x" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -5063,25 +4559,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5715,16 +5192,6 @@ "minimalistic-crypto-utils": "^1.0.1" } }, - "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -6017,16 +5484,6 @@ "node": ">=12" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -6229,13 +5686,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6509,16 +5959,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6565,13 +6005,6 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -8004,6 +7437,17 @@ "node": ">= 0.8" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -8117,16 +7561,6 @@ "node": ">=6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -8346,39 +7780,37 @@ } }, "node_modules/powerbi-visuals-tools": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/powerbi-visuals-tools/-/powerbi-visuals-tools-7.1.0.tgz", - "integrity": "sha512-U9XmM2OjZCKG7nx/82VfFjQd1vw5Tnlsi9dlkFh3UJn4TsECFrMDDMDq4AlwrlWmzdBCrJrffmrgs5vDiSsRXg==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/powerbi-visuals-tools/-/powerbi-visuals-tools-7.0.3.tgz", + "integrity": "sha512-E3RhQAJgcBSelYEVk/nQl8Qk/CRx2mne2jVmOMrTZomrodsrVIc66cc5b1gLtLGIkXKkuXtOO/vA1IRNt+XloA==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.3", "@typescript-eslint/parser": "^8.50.0", "async": "^3.2.6", "chalk": "^5.4.1", - "commander": "^14.0.3", + "commander": "^13.1.0", "compare-versions": "^6.1.1", "css-loader": "^7.1.4", - "eslint-plugin-powerbi-visuals": "1.1.1", - "fs-extra": "^11.3.4", + "eslint-plugin-powerbi-visuals": "1.1.0", + "fs-extra": "^11.3.3", "inline-source-map": "^0.6.3", "json-loader": "0.5.7", "jszip": "^3.10.1", - "less": "^4.6.4", - "less-loader": "^12.3.2", + "less": "^4.5.1", + "less-loader": "^12.3.1", "lodash.clonedeep": "4.5.0", "lodash.defaults": "4.2.0", "lodash.isequal": "4.5.0", "lodash.ismatch": "^4.4.0", - "mini-css-extract-plugin": "^2.10.2", - "powerbi-visuals-webpack-plugin": "^5.0.1", - "terser-webpack-plugin": "^5.4.0", + "mini-css-extract-plugin": "^2.10.0", + "powerbi-visuals-webpack-plugin": "^5.0.0", + "terser-webpack-plugin": "^5.3.16", "ts-loader": "^9.5.4", "typescript": "^5.9.3", - "webpack": "^5.105.4", + "webpack": "^5.105.3", "webpack-bundle-analyzer": "4.10.2", - "webpack-dev-server": "^5.2.3", - "zod": "^4.3.6" + "webpack-dev-server": "^5.2.3" }, "bin": { "pbiviz": "bin/pbiviz.js" @@ -8857,9 +8289,9 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -9143,59 +8575,6 @@ "dev": true, "license": "MIT" }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/router/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -9563,9 +8942,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "dev": true, "license": "MIT", "engines": { @@ -9692,41 +9071,15 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.20.1" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", "dev": true, - "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "debug": "~4.3.4", + "ws": "~8.17.1" } }, - "node_modules/socket.io-adapter/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/socket.io-parser": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", @@ -10271,9 +9624,9 @@ } }, "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", "engines": { @@ -10639,12 +9992,29 @@ "dev": true, "license": "MIT" }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -11063,6 +10433,28 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/webpack-merge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz", @@ -11260,11 +10652,10 @@ "dev": true }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -11377,26 +10768,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } } } } diff --git a/src/heatmapUtils.ts b/src/heatmapUtils.ts index e9db136..2baa9cf 100644 --- a/src/heatmapUtils.ts +++ b/src/heatmapUtils.ts @@ -32,7 +32,7 @@ import { ColorHelper } from "powerbi-visuals-utils-colorutils"; import maxBy from "lodash.maxby"; -import { color as d3Color, hsl as d3Hsl, lab as d3Lab } from "d3-color"; +import { color as d3Color, hsl as d3Hsl, lab as d3Lab, RGBColor } from "d3-color"; import { TableHeatMapChartData } from "./dataInterfaces"; import { BaseLabelCardSettings, GeneralSettings, SettingsModel, YAxisLabelsSettings } from "./settings"; @@ -163,6 +163,83 @@ export function parseSettings(colorHelper: ColorHelper, settingsModel: SettingsM return settingsModel; } +// --------------------------------------------------------------------------- +// WCAG 2.x relative luminance helpers (W3C formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef) +// --------------------------------------------------------------------------- + +// sRGB linearization coefficients (IEC 61966-2-1) +const SRGB_CHANNEL_MAX = 255; // maximum 8-bit channel value +const SRGB_LINEARIZATION_THRESHOLD = 0.03928; // below this, use linear segment +const SRGB_LINEAR_DIVISOR = 12.92; // divisor for the linear segment +const SRGB_EXPONENT_OFFSET = 0.055; // offset in the power-law segment +const SRGB_EXPONENT_SCALE = 1.055; // scale in the power-law segment +const SRGB_GAMMA = 2.4; // gamma exponent (IEC 61966-2-1) + +// WCAG 2.x relative-luminance coefficients (ITU-R BT.709 primaries) +const WCAG_RED_COEFF = 0.2126; +const WCAG_GREEN_COEFF = 0.7152; +const WCAG_BLUE_COEFF = 0.0722; + +// Offset added to both luminances in the WCAG contrast-ratio formula +const WCAG_LUMINANCE_OFFSET = 0.05; + +/** WCAG AA contrast ratio target for normal text. */ +export const WCAG_AA_CONTRAST_RATIO: number = 4.5; + +/** + * WCAG crossover luminance: the background luminance at which dark and light + * text yield equal contrast ratios. Derived from (L+0.05)/0.05 = 1.05/(L+0.05) + * → L ≈ 0.179. + */ +const WCAG_CROSSOVER_LUMINANCE = 0.179; + +/** Iterations for the binary-search in Strong mode: 2^-20 ≈ 10^-6 lightness precision. */ +const BINARY_SEARCH_ITERATIONS = 20; + +/** Auto-contrast mode identifiers — must match the enumeration values in capabilities.json. */ +export const AUTO_CONTRAST_MODE_OFF = "Off" as const; +export const AUTO_CONTRAST_MODE_SOFT = "Soft" as const; +export const AUTO_CONTRAST_MODE_STRONG = "Strong" as const; + +export type AutoContrastMode = + typeof AUTO_CONTRAST_MODE_OFF | + typeof AUTO_CONTRAST_MODE_SOFT | + typeof AUTO_CONTRAST_MODE_STRONG; + +/** sRGB channel 0–255 → linear-light value (IEC 61966-2-1). */ +function linearizeChannel(c255: number): number { + const c = c255 / SRGB_CHANNEL_MAX; + return c <= SRGB_LINEARIZATION_THRESHOLD + ? c / SRGB_LINEAR_DIVISOR + : ((c + SRGB_EXPONENT_OFFSET) / SRGB_EXPONENT_SCALE) ** SRGB_GAMMA; +} + +/** WCAG 2.x relative luminance in [0, 1]. */ +function relativeLuminance(rgb: RGBColor): number { + return WCAG_RED_COEFF * linearizeChannel(rgb.r) + + WCAG_GREEN_COEFF * linearizeChannel(rgb.g) + + WCAG_BLUE_COEFF * linearizeChannel(rgb.b); +} + +/** WCAG 2.x contrast ratio; inputs are relative luminances. */ +function contrastRatioFromLuminances(l1: number, l2: number): number { + const [light, dark] = l1 > l2 ? [l1, l2] : [l2, l1]; + return (light + WCAG_LUMINANCE_OFFSET) / (dark + WCAG_LUMINANCE_OFFSET); +} + +/** + * Returns the WCAG 2.x contrast ratio between two CSS colour strings, + * or `null` if either is invalid/unparseable. + */ +export function wcagContrastRatio(color1: string, color2: string): number | null { + const c1 = d3Color(color1); + const c2 = d3Color(color2); + if (c1 === null || c2 === null) return null; + return contrastRatioFromLuminances(relativeLuminance(c1.rgb()), relativeLuminance(c2.rgb())); +} + +// --------------------------------------------------------------------------- + /** * Preserves the user's hue/saturation and alpha; clamps only lightness to stay legible on `backgroundColor`. * @@ -184,3 +261,52 @@ export function getAdaptiveLabelColor(userColor: string, backgroundColor: string return fg.formatRgb(); } +/** + * Strong mode: binary-search HSL lightness until WCAG AA contrast ratio (≥ 4.5:1) is met. + * Preserves hue, saturation, and alpha; only adjusts lightness. + * + * The search starts from the Soft-mode target direction (DARK_LABEL_LIGHTNESS toward 0 for + * light backgrounds, LIGHT_LABEL_LIGHTNESS toward 1 for dark backgrounds), so it makes the + * smallest possible lightness change that achieves the required contrast. + */ +export function getAdaptiveLabelColorStrong(userColor: string, backgroundColor: string): string { + const bgParsed = d3Color(backgroundColor); + const fg = d3Hsl(userColor); + if (bgParsed === null || fg === null || isNaN(fg.l)) { + return userColor; + } + const bgLum = relativeLuminance(bgParsed.rgb()); + const useDark = bgLum > WCAG_CROSSOVER_LUMINANCE; // dark text on light bg + + // Binary-search range: + // dark text → maximise l within [0, DARK_LABEL_LIGHTNESS] (l=0 always satisfies) + // light text → minimise l within [LIGHT_LABEL_LIGHTNESS, 1] (l=1 always satisfies) + let lo = useDark ? 0 : LIGHT_LABEL_LIGHTNESS; + let hi = useDark ? DARK_LABEL_LIGHTNESS : 1; + + for (let i = 0; i < BINARY_SEARCH_ITERATIONS; i++) { + const mid = (lo + hi) / 2; + fg.l = mid; + const fgLum = relativeLuminance(fg.rgb()); + if (contrastRatioFromLuminances(fgLum, bgLum) >= WCAG_AA_CONTRAST_RATIO) { + // Meets the ratio — can relax toward the user's preferred direction + if (useDark) lo = mid; else hi = mid; + } else { + // Fails — push toward the extreme + if (useDark) hi = mid; else lo = mid; + } + } + fg.l = useDark ? lo : hi; + return fg.formatRgb(); +} + +/** + * Dispatcher: routes to the appropriate contrast algorithm based on `mode`. + * @param mode One of the AUTO_CONTRAST_MODE_* constants. + */ +export function applyAutoContrast(userColor: string, backgroundColor: string, mode: AutoContrastMode): string { + if (mode === AUTO_CONTRAST_MODE_OFF) return userColor; + if (mode === AUTO_CONTRAST_MODE_STRONG) return getAdaptiveLabelColorStrong(userColor, backgroundColor); + return getAdaptiveLabelColor(userColor, backgroundColor); // Soft +} + diff --git a/src/settings.ts b/src/settings.ts index 5d3f22c..c48da02 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -23,7 +23,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ -import { formattingSettings } from "powerbi-visuals-utils-formattingmodel"; +import { formattingSettings, formattingSettingsInterfaces } from "powerbi-visuals-utils-formattingmodel"; +type ILocalizedItemMember = formattingSettingsInterfaces.ILocalizedItemMember; import FormattingSettingsSimpleCard = formattingSettings.SimpleCard; import FormattingSettingsCompositeCard = formattingSettings.CompositeCard; @@ -562,11 +563,17 @@ export class BaseLabelCardSettings extends FormattingSettingsSimpleCard { } } +export const AUTO_CONTRAST_OPTION_OFF: ILocalizedItemMember = { displayNameKey: "Visual_LabelsAutoContrast_Off", value: "Off" }; +const AUTO_CONTRAST_OPTION_SOFT: ILocalizedItemMember = { displayNameKey: "Visual_LabelsAutoContrast_Soft", value: "Soft" }; +const AUTO_CONTRAST_OPTION_STRONG: ILocalizedItemMember = { displayNameKey: "Visual_LabelsAutoContrast_Strong", value: "Strong" }; +const autoContrastOptions: ILocalizedItemMember[] = [AUTO_CONTRAST_OPTION_OFF, AUTO_CONTRAST_OPTION_SOFT, AUTO_CONTRAST_OPTION_STRONG]; + export class DataLabelsCardSettings extends BaseLabelCardSettings { - public autoContrast = new formattingSettings.ToggleSwitch({ + public autoContrast = new formattingSettings.ItemDropdown({ name: "autoContrast", displayNameKey: "Visual_LabelsAutoContrast", - value: false + items: autoContrastOptions, + value: AUTO_CONTRAST_OPTION_SOFT, // default: Soft }); constructor(name: string, displayNameKey: string, isShown: boolean = true) { diff --git a/src/visual.ts b/src/visual.ts index b2f3c02..f554b78 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -84,7 +84,10 @@ import { calculateGridSizeHeight, calculateGridSizeWidth, CellMaxHeightLimit, - getAdaptiveLabelColor, + applyAutoContrast, + AUTO_CONTRAST_MODE_OFF, + AUTO_CONTRAST_MODE_SOFT, + AutoContrastMode, getXAxisHeight, getYAxisHeight, getYAxisWidth, @@ -597,6 +600,10 @@ export class TableHeatMap implements IVisual { // Cache adapted colors by (userColor|backgroundColor) key; avoids redundant Lab/HSL // conversions for the same background bucket on every label within a single render pass. const adaptiveLabelColorCache = new Map(); + // .value → ILocalizedItemMember (selected option); .value.value → the raw EnumMemberValue string. + // Legacy boolean migration (false/true) is handled in update() before this point. + // Fall back to Soft (the configured default) if the value is somehow absent. + const autoContrastMode = (labelSettings.autoContrast.value?.value as AutoContrastMode | undefined) ?? AUTO_CONTRAST_MODE_SOFT; const heatMapDataLables: Selection = this.mainGraphics .selectAll(TableHeatMap.ClsHeatMapDataLabels.selectorName) @@ -613,7 +620,7 @@ export class TableHeatMap implements IVisual { .call(this.applyFontStylesToLabels(labelSettings)) .style("fill", (dataPoint: TableHeatMapDataPoint) => { const userColor: string = labelSettings.fill.value.value; - if (!labelSettings.autoContrast?.value) { + if (autoContrastMode === AUTO_CONTRAST_MODE_OFF) { return userColor; } const value = dataPoint.value; @@ -629,7 +636,7 @@ export class TableHeatMap implements IVisual { if (cached !== undefined) { return cached; } - const adapted = getAdaptiveLabelColor(userColor, backgroundColor); + const adapted = applyAutoContrast(userColor, backgroundColor, autoContrastMode); adaptiveLabelColorCache.set(cacheKey, adapted); return adapted; }) diff --git a/stringResources/en-US/resources.resjson b/stringResources/en-US/resources.resjson index add58da..1b643ea 100644 --- a/stringResources/en-US/resources.resjson +++ b/stringResources/en-US/resources.resjson @@ -17,6 +17,9 @@ "Visual_InvertColorScale": "Invert color scale", "Visual_LabelsFill": "Color", "Visual_LabelsAutoContrast": "Auto-contrast", + "Visual_LabelsAutoContrast_Off": "Off", + "Visual_LabelsAutoContrast_Soft": "Soft", + "Visual_LabelsAutoContrast_Strong": "Strong", "Visual_Long_Description": "Use this custom visual to build a table heat map that can be used to visualise and compare data values in an easy and intuitive way.\nYou have a built-in option within this visual to specify the number of buckets used for splitting your data.\nAdditionally, you can also customise it by choosing a colour scheme in line with your brand colours", "Visual_MaxTextSymbols": "Max text symbols", "Visual_MaxValue": "Max value", diff --git a/test/visualTest.ts b/test/visualTest.ts index d7a20e8..a897cf7 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -51,7 +51,14 @@ import { ConstGridMinHeight, CellMaxHeightLimit, ConstGridMinWidth, CellMaxWidthFactorLimit, getYAxisWidth, getXAxisHeight, getYAxisHeight, parseSettings, - getAdaptiveLabelColor + getAdaptiveLabelColor, + getAdaptiveLabelColorStrong, + WCAG_AA_CONTRAST_RATIO, + wcagContrastRatio, + applyAutoContrast, + AUTO_CONTRAST_MODE_OFF, + AUTO_CONTRAST_MODE_SOFT, + AUTO_CONTRAST_MODE_STRONG } from "../src/heatmapUtils"; const DefaultTimeout: number = 300; @@ -1257,14 +1264,14 @@ describe("TableHeatmap", () => { }); }); - describe("toggle: autoContrast OFF", () => { + describe("mode: Off", () => { it("all labels use the exact user-picked fill color", (done) => { const userColor = "#ff6600"; dataView.metadata.objects = { labels: { show: true, fill: { solid: { color: userColor } }, - autoContrast: false + autoContrast: "Off" } }; @@ -1280,11 +1287,11 @@ describe("TableHeatmap", () => { }); }); - describe("toggle: autoContrast OFF → ON", () => { + describe("mode: Soft", () => { it("at least one label fill differs from the static user-picked color", (done) => { const userColor = "#888888"; dataView.metadata.objects = { - labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: false } + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: "Off" } }; visualBuilder.updateRenderTimeout(dataView, () => { @@ -1292,7 +1299,7 @@ describe("TableHeatmap", () => { .map(el => getComputedStyle(el)["fill"]); dataView.metadata.objects = { - labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: true } + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: "Soft" } }; visualBuilder.updateRenderTimeout(dataView, () => { @@ -1308,7 +1315,7 @@ describe("TableHeatmap", () => { it("no label has an empty or transparent fill", (done) => { dataView.metadata.objects = { - labels: { show: true, autoContrast: true } + labels: { show: true, autoContrast: "Soft" } }; visualBuilder.updateRenderTimeout(dataView, () => { @@ -1324,15 +1331,15 @@ describe("TableHeatmap", () => { }, DefaultTimeout); }); - it("toggling back OFF restores the user-picked fill color on all labels", (done) => { + it("toggling back to Off restores the user-picked fill color on all labels", (done) => { const userColor = "#3399ff"; dataView.metadata.objects = { - labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: true } + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: "Soft" } }; visualBuilder.updateRenderTimeout(dataView, () => { dataView.metadata.objects = { - labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: false } + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: "Off" } }; visualBuilder.updateRenderTimeout(dataView, () => { @@ -1351,7 +1358,7 @@ describe("TableHeatmap", () => { const userColor = "#ff0000"; dataView.metadata.objects = { general: { fillNullValuesCells: true }, - labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: true } + labels: { show: true, fill: { solid: { color: userColor } }, autoContrast: "Soft" } }; dataView.categorical!.values![0].values![0] = null; @@ -1368,6 +1375,50 @@ describe("TableHeatmap", () => { }, DefaultTimeout); }); }); + + describe("mode: Strong", () => { + it("achieves WCAG AA contrast (>=4.5:1) for red on a white background", () => { + const result = getAdaptiveLabelColorStrong("#ff0000", "#ffffff"); + expect(wcagContrastRatio(result, "#ffffff")).toBeGreaterThanOrEqual(WCAG_AA_CONTRAST_RATIO); + }); + + it("achieves WCAG AA contrast (>=4.5:1) for red on a black background", () => { + const result = getAdaptiveLabelColorStrong("#ff0000", "#000000"); + expect(wcagContrastRatio(result, "#000000")).toBeGreaterThanOrEqual(WCAG_AA_CONTRAST_RATIO); + }); + + it("Strong guarantees WCAG AA where Soft undershoots (blue on dark gray #555555)", () => { + // Soft clamps to HSL l=0.85 → contrast ≈3.6:1 (below AA). + // Strong binary-searches higher until 4.5:1 is met. + const bg = "#555555"; + const soft = getAdaptiveLabelColor("#0000ff", bg); + const strong = getAdaptiveLabelColorStrong("#0000ff", bg); + expect(wcagContrastRatio(soft, bg)!).toBeLessThan(WCAG_AA_CONTRAST_RATIO); + expect(wcagContrastRatio(strong, bg)!).toBeGreaterThanOrEqual(WCAG_AA_CONTRAST_RATIO); + }); + }); + + describe("applyAutoContrast dispatch", () => { + it("Off mode returns the user color unchanged", () => { + const userColor = "#e84e1e"; + expect(applyAutoContrast(userColor, "#ffffff", AUTO_CONTRAST_MODE_OFF)).toBe(userColor); + }); + + it("Soft mode delegates to getAdaptiveLabelColor", () => { + const userColor = "#888888"; + const bg = "#ffffff"; + expect(applyAutoContrast(userColor, bg, AUTO_CONTRAST_MODE_SOFT)) + .toBe(getAdaptiveLabelColor(userColor, bg)); + }); + + it("Strong mode delegates to getAdaptiveLabelColorStrong", () => { + const userColor = "#0000ff"; + const bg = "#555555"; + expect(applyAutoContrast(userColor, bg, AUTO_CONTRAST_MODE_STRONG)) + .toBe(getAdaptiveLabelColorStrong(userColor, bg)); + }); + + }); }); }); From 1004cfe2b1efc6253d9bc0718ed56d01df36e26d Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Fri, 19 Jun 2026 16:42:57 +0500 Subject: [PATCH 8/9] fix: Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/visual.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/visual.ts b/src/visual.ts index f554b78..1090521 100644 --- a/src/visual.ts +++ b/src/visual.ts @@ -601,8 +601,7 @@ export class TableHeatMap implements IVisual { // conversions for the same background bucket on every label within a single render pass. const adaptiveLabelColorCache = new Map(); // .value → ILocalizedItemMember (selected option); .value.value → the raw EnumMemberValue string. - // Legacy boolean migration (false/true) is handled in update() before this point. - // Fall back to Soft (the configured default) if the value is somehow absent. + // Fall back to Soft (the configured default) if the value is absent. const autoContrastMode = (labelSettings.autoContrast.value?.value as AutoContrastMode | undefined) ?? AUTO_CONTRAST_MODE_SOFT; const heatMapDataLables: Selection = this.mainGraphics From 32b4a6558c3c4805fb667d711dd7094b2f230fa4 Mon Sep 17 00:00:00 2001 From: Ansagan Islamgali Date: Fri, 19 Jun 2026 17:01:59 +0500 Subject: [PATCH 9/9] feat: enhance adaptive label color logic to support alpha-compositing for WCAG AA compliance --- src/heatmapUtils.ts | 21 ++++++++++++++++++--- test/visualTest.ts | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/heatmapUtils.ts b/src/heatmapUtils.ts index 2baa9cf..6770c39 100644 --- a/src/heatmapUtils.ts +++ b/src/heatmapUtils.ts @@ -186,6 +186,10 @@ const WCAG_LUMINANCE_OFFSET = 0.05; /** WCAG AA contrast ratio target for normal text. */ export const WCAG_AA_CONTRAST_RATIO: number = 4.5; +// formatRgb() rounds r/g/b to integers on output, which can lower the contrast by up to ~0.02. +// The binary search targets this slightly higher value so the rounded output still clears 4.5:1. +const WCAG_AA_BINARY_SEARCH_TARGET: number = WCAG_AA_CONTRAST_RATIO + 0.05; + /** * WCAG crossover luminance: the background luminance at which dark and light * text yield equal contrast ratios. Derived from (L+0.05)/0.05 = 1.05/(L+0.05) @@ -275,7 +279,8 @@ export function getAdaptiveLabelColorStrong(userColor: string, backgroundColor: if (bgParsed === null || fg === null || isNaN(fg.l)) { return userColor; } - const bgLum = relativeLuminance(bgParsed.rgb()); + const bgRgb = bgParsed.rgb(); + const bgLum = relativeLuminance(bgRgb); const useDark = bgLum > WCAG_CROSSOVER_LUMINANCE; // dark text on light bg // Binary-search range: @@ -287,8 +292,18 @@ export function getAdaptiveLabelColorStrong(userColor: string, backgroundColor: for (let i = 0; i < BINARY_SEARCH_ITERATIONS; i++) { const mid = (lo + hi) / 2; fg.l = mid; - const fgLum = relativeLuminance(fg.rgb()); - if (contrastRatioFromLuminances(fgLum, bgLum) >= WCAG_AA_CONTRAST_RATIO) { + const fgRgb = fg.rgb(); + // If the user color has alpha < 1, the perceived text is the alpha-composite of fg over bg. + // Compute luminance on the composited colour so the contrast check reflects what is rendered. + const a = fgRgb.opacity ?? 1; + const compR = fgRgb.r * a + bgRgb.r * (1 - a); + const compG = fgRgb.g * a + bgRgb.g * (1 - a); + const compB = fgRgb.b * a + bgRgb.b * (1 - a); + const fgLum = + WCAG_RED_COEFF * linearizeChannel(compR) + + WCAG_GREEN_COEFF * linearizeChannel(compG) + + WCAG_BLUE_COEFF * linearizeChannel(compB); + if (contrastRatioFromLuminances(fgLum, bgLum) >= WCAG_AA_BINARY_SEARCH_TARGET) { // Meets the ratio — can relax toward the user's preferred direction if (useDark) lo = mid; else hi = mid; } else { diff --git a/test/visualTest.ts b/test/visualTest.ts index a897cf7..2e4b76f 100644 --- a/test/visualTest.ts +++ b/test/visualTest.ts @@ -1396,6 +1396,24 @@ describe("TableHeatmap", () => { expect(wcagContrastRatio(soft, bg)!).toBeLessThan(WCAG_AA_CONTRAST_RATIO); expect(wcagContrastRatio(strong, bg)!).toBeGreaterThanOrEqual(WCAG_AA_CONTRAST_RATIO); }); + + it("Strong achieves WCAG AA for semi-transparent user color (alpha-compositing test)", () => { + // rgba(0,0,255,0.8) on #444444: at LIGHT_LABEL_LIGHTNESS (~0.85) the opaque blue just + // passes AA (~5.0:1), but the alpha-composited result drops to ~3.8:1 — below AA. + // The fix must composite fg over bg before checking so the search settles higher. + const bg = "#444444"; + const bgR = 0x44, bgG = 0x44, bgB = 0x44; + const result = getAdaptiveLabelColorStrong("rgba(0, 0, 255, 0.8)", bg); + // Parse the returned rgba(r,g,b,a) — formatRgb() emits 'rgba(...)' when opacity<1. + const match = result.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + expect(match).not.toBeNull(); + const a = match![4] !== undefined ? parseFloat(match![4]) : 1; + const compR = Math.round(parseInt(match![1]) * a + bgR * (1 - a)); + const compG = Math.round(parseInt(match![2]) * a + bgG * (1 - a)); + const compB = Math.round(parseInt(match![3]) * a + bgB * (1 - a)); + expect(wcagContrastRatio(`rgb(${compR}, ${compG}, ${compB})`, bg)) + .toBeGreaterThanOrEqual(WCAG_AA_CONTRAST_RATIO); + }); }); describe("applyAutoContrast dispatch", () => {