diff --git a/bun.lock b/bun.lock index 9cedf14d..7a866281 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^7.1.2", + "@types/d3-dispatch": "3.0.6", "@types/jest": "^28.1.3", "@types/lodash": "^4.17.20", "@types/node": "^14.14.9", @@ -48,6 +49,7 @@ "eslint": "^8.57.0", "eslint-plugin-sonarjs": "^0.25.0", "gh-pages": "^5.0.0", + "happy-dom": "^20.8.7", "husky": "^9.1.7", "react-scripts": "5.0.0", "react-test-renderer": "^18.2.0", @@ -58,6 +60,7 @@ }, }, "overrides": { + "@types/d3-dispatch": "3.0.6", "express": "4.21.0", "make-dir": "3.1.0", "nth-check": "2.0.1", @@ -650,6 +653,8 @@ "@types/connect-history-api-fallback": ["@types/connect-history-api-fallback@1.3.5", "", { "dependencies": { "@types/express-serve-static-core": "*", "@types/node": "*" } }, "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw=="], + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="], + "@types/eslint": ["@types/eslint@8.4.10", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-Sl/HOqN8NKPmhWo2VBEPm0nvHnu2LL3v9vKo8MEq0EtbJ4eVzGPl41VNPvn5E1i5poMk4/XD8UriLHpJvEP/Nw=="], "@types/eslint-scope": ["@types/eslint-scope@3.7.4", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA=="], @@ -734,7 +739,9 @@ "@types/uuid": ["@types/uuid@8.3.4", "", {}, "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw=="], - "@types/ws": ["@types/ws@8.5.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@16.0.4", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw=="], @@ -1308,7 +1315,7 @@ "enhanced-resolve": ["enhanced-resolve@5.12.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-ci": ["env-ci@9.1.1", "", { "dependencies": { "execa": "^7.0.0", "java-properties": "^1.0.2" } }, "sha512-Im2yEWeF4b2RAMAaWvGioXk6m0UNaIjD8hj28j2ij5ldnIFrDQT0+pzDvpbRkcjurhXhf/AsBKv8P2rtmGi9Aw=="], @@ -1554,6 +1561,8 @@ "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "happy-dom": ["happy-dom@20.8.7", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-7wfBi+UqulQlyLcis+9a+hTK0A/fMO4QKP6w6J9HnadXVkRdOvGf/N5G4XVpfgCYfnY7oKazlOSdWmsfatNSLQ=="], + "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], "harmony-reflect": ["harmony-reflect@1.6.2", "", {}, "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g=="], @@ -2910,12 +2919,14 @@ "typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="], - "typescript": ["typescript@4.9.4", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg=="], + "typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], "uglify-js": ["uglify-js@3.17.4", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g=="], "unbox-primitive": ["unbox-primitive@1.0.2", "", { "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", "has-symbols": "^1.0.3", "which-boxed-primitive": "^1.0.2" } }, "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.0", "", {}, "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ=="], "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], @@ -3008,7 +3019,7 @@ "whatwg-fetch": ["whatwg-fetch@3.6.2", "", {}, "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA=="], - "whatwg-mimetype": ["whatwg-mimetype@2.3.0", "", {}, "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], @@ -3068,7 +3079,7 @@ "write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "ws": ["ws@8.18.0", "", {}, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "xml-name-validator": ["xml-name-validator@3.0.0", "", {}, "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw=="], @@ -3272,7 +3283,7 @@ "@types/testing-library__jest-dom/@types/jest": ["@types/jest@29.2.4", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-PipFB04k2qTRPePduVLTRiPzQfvMeLwUN3Z21hsAKaB/W9IIzgB2pizCL466ftJlcyZqnHoC9ZHpxLGl3fS86A=="], - "@types/ws/@types/node": ["@types/node@18.11.17", "", {}, "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng=="], + "@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@5.2.4", "", {}, "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="], @@ -3338,6 +3349,8 @@ "cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="], + "data-urls/whatwg-mimetype": ["whatwg-mimetype@2.3.0", "", {}, "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="], + "data-urls/whatwg-url": ["whatwg-url@8.7.0", "", { "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", "webidl-conversions": "^6.1.0" } }, "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg=="], "dcmjs/adm-zip": ["adm-zip@0.5.10", "", {}, "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="], @@ -3350,6 +3363,8 @@ "dicom-microscopy-viewer/dcmjs": ["dcmjs@0.41.0", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-kr46REomItFeWz+0ck4Wif4uS5VVDWVlwdh5GGaCtTYHWfNQmrcCSiQOkrShc7Dc5zP8vNKrHEdORlZXenlg3w=="], + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "domexception/webidl-conversions": ["webidl-conversions@5.0.0", "", {}, "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA=="], "domutils/domelementtype": ["domelementtype@1.3.1", "", {}, "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="], @@ -3442,6 +3457,8 @@ "globby/path-type": ["path-type@5.0.0", "", {}, "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg=="], + "happy-dom/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "hasown/function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "hosted-git-info/lru-cache": ["lru-cache@10.2.2", "", {}, "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="], @@ -3452,6 +3469,8 @@ "htmlparser2/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], + "htmlparser2/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "http-proxy/follow-redirects": ["follow-redirects@1.15.2", "", {}, "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="], "humanize-ms/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -3656,6 +3675,8 @@ "jsdom/webidl-conversions": ["webidl-conversions@6.1.0", "", {}, "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@2.3.0", "", {}, "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="], + "jsdom/whatwg-url": ["whatwg-url@8.7.0", "", { "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", "webidl-conversions": "^6.1.0" } }, "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg=="], "jsdom/ws": ["ws@7.5.10", "", {}, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], @@ -3896,8 +3917,12 @@ "webpack-dev-middleware/schema-utils": ["schema-utils@4.0.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.8.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.0.0" } }, "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg=="], + "webpack-dev-server/@types/ws": ["@types/ws@8.5.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w=="], + "webpack-dev-server/schema-utils": ["schema-utils@4.0.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.8.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.0.0" } }, "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg=="], + "webpack-dev-server/ws": ["ws@8.18.0", "", {}, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "webpack-manifest-plugin/webpack-sources": ["webpack-sources@2.3.1", "", { "dependencies": { "source-list-map": "^2.0.1", "source-map": "^0.6.1" } }, "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA=="], "whatwg-encoding/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -4488,6 +4513,8 @@ "webpack-dev-middleware/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "webpack-dev-server/@types/ws/@types/node": ["@types/node@18.11.17", "", {}, "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng=="], + "webpack-dev-server/schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "webpack-dev-server/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], @@ -4856,6 +4883,8 @@ "read-pkg/parse-json/@babel/code-frame/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "renderkid/css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "semantic-release/@semantic-release/github/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="], "semantic-release/@semantic-release/github/@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], @@ -4974,6 +5003,8 @@ "pkg-conf/find-up/locate-path/p-locate/p-limit/p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], + "postcss-svgo/svgo/css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "read-pkg/parse-json/@babel/code-frame/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "read-pkg/parse-json/@babel/code-frame/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..9c5cd558 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/test/bun-preload.ts"] diff --git a/package.json b/package.json index ec4d8ba9..38655c59 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "fmt": "biome format --write .", "format": "biome format --write .", "test": "biome check . && craco test --watchAll=false", + "test:bun": "bun test", "predeploy": "REACT_APP_CONFIG=demo PUBLIC_URL='https://imagingdatacommons.github.io/slim/' ./scripts/set-git-env.sh craco build", "deploy": "gh-pages -d build", "clean": "rm -rf ./build ./node_modules", @@ -52,11 +53,10 @@ "retry": "^0.13.1" }, "devDependencies": { - "ajv": "6.12.6", - "@biomejs/biome": "^2.0.0", "@babel/preset-env": "^7.15.0", "@babel/preset-react": "^7.17.12", "@babel/preset-typescript": "^7.17.12", + "@biomejs/biome": "^2.0.0", "@craco/craco": "^6.4.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^12.0.0", @@ -67,6 +67,7 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^7.1.2", + "@types/d3-dispatch": "3.0.6", "@types/jest": "^28.1.3", "@types/lodash": "^4.17.20", "@types/node": "^14.14.9", @@ -75,11 +76,13 @@ "@types/react-router-dom": "^5.3.3", "@types/retry": "^0.12.1", "@types/uuid": "^8.3.0", + "ajv": "6.12.6", "copy-webpack-plugin": "9.1.0", "craco-less": "^2.0.0", "eslint": "^8.57.0", "eslint-plugin-sonarjs": "^0.25.0", "gh-pages": "^5.0.0", + "happy-dom": "^20.8.7", "husky": "^9.1.7", "react-scripts": "5.0.0", "react-test-renderer": "^18.2.0", @@ -88,6 +91,7 @@ "typescript": "^4.7.4" }, "overrides": { + "@types/d3-dispatch": "3.0.6", "nth-check": "2.0.1", "wrap-ansi": "7.0.0", "make-dir": "3.1.0", diff --git a/src/__mocks__/dicomMicroscopyViewerMock.js b/src/__mocks__/dicomMicroscopyViewerMock.js index 97bb9c00..b2b02afe 100644 --- a/src/__mocks__/dicomMicroscopyViewerMock.js +++ b/src/__mocks__/dicomMicroscopyViewerMock.js @@ -14,6 +14,7 @@ const TAG_TO_KEYWORD = { '00201206': 'NumberOfStudyRelatedSeries', '00201208': 'NumberOfStudyRelatedInstances', '00080061': 'ModalitiesInStudy', + '00080060': 'Modality', '00080090': 'ReferringPhysicianName', '0020000E': 'SeriesInstanceUID' } diff --git a/src/components/DicomTagBrowser/DicomTagBrowser.tsx b/src/components/DicomTagBrowser/DicomTagBrowser.tsx index 02a3056e..0e8d546e 100644 --- a/src/components/DicomTagBrowser/DicomTagBrowser.tsx +++ b/src/components/DicomTagBrowser/DicomTagBrowser.tsx @@ -13,6 +13,7 @@ import DicomMetadataStore, { type Study, } from '../../services/DICOMMetadataStore' import { formatDicomDate } from '../../utils/formatDicomDate' +import { logger } from '../../utils/logger' import { getSortedTags, type TagInfo } from './dicomTagUtils' const { Option } = Select @@ -43,6 +44,16 @@ interface DicomTagBrowserProps { seriesInstanceUID?: string } +function bucketContainsSopInstance(bucket: unknown[], sop: string): boolean { + if (sop === '') return false + for (const existing of bucket) { + if ((existing as Record).SOPInstanceUID === sop) { + return true + } + } + return false +} + const DicomTagBrowser = ({ clients, studyInstanceUID, @@ -109,50 +120,65 @@ const DicomTagBrowser = ({ if (slides.length > 0) { displaySets = slides .flatMap((slide): DisplaySet[] => { - const slideDisplaySets: DisplaySet[] = [] + /** One row per SeriesInstanceUID; volume/overview/label often share a series. */ + const imagesBySeries = new Map() - // Helper function to process any image type - const processImageType = ( + const addImages = ( images: unknown[] | undefined, imageType: string, ): void => { - if (images?.[0] !== undefined) { - console.info( - `Found ${images.length} ${imageType} image(s) for slide ${slide.containerIdentifier}`, - ) - - const img = images[0] as Record - const { - SeriesDate, - SeriesTime, - SeriesNumber, - SeriesInstanceUID, - SeriesDescription, - Modality, - } = img - - processedSeries.push(SeriesInstanceUID as string) - - const ds: DisplaySet = { - displaySetInstanceUID: index, - SeriesDate: SeriesDate as string | undefined, - SeriesTime: SeriesTime as string | undefined, - SeriesInstanceUID: SeriesInstanceUID as string, - SeriesNumber: String(SeriesNumber), - SeriesDescription: SeriesDescription as string | undefined, - Modality: Modality as string, - images, + if (images?.[0] === undefined) return + logger.debug( + `Found ${images.length} ${imageType} image(s) for slide ${slide.containerIdentifier}`, + ) + for (const image of images) { + const img = image as Record + const seriesUID = img.SeriesInstanceUID as string | undefined + if (seriesUID === undefined || seriesUID === '') continue + + let bucket = imagesBySeries.get(seriesUID) + if (bucket === undefined) { + processedSeries.push(seriesUID) + bucket = [] + imagesBySeries.set(seriesUID, bucket) + } + + const sop = + typeof img.SOPInstanceUID === 'string' ? img.SOPInstanceUID : '' + if (!bucketContainsSopInstance(bucket, sop)) { + bucket.push(image) } - slideDisplaySets.push(ds) - index++ } } - // Process all image types - processImageType(slide.volumeImages, 'volume') - processImageType(slide.overviewImages, 'overview') - processImageType(slide.labelImages, 'label') + addImages(slide.volumeImages, 'volume') + addImages(slide.overviewImages, 'overview') + addImages(slide.labelImages, 'label') + const slideDisplaySets: DisplaySet[] = [] + for (const images of imagesBySeries.values()) { + if (images[0] === undefined) continue + const img = images[0] as Record + const { + SeriesDate, + SeriesTime, + SeriesNumber, + SeriesInstanceUID, + SeriesDescription, + Modality, + } = img + slideDisplaySets.push({ + displaySetInstanceUID: index, + SeriesDate: SeriesDate as string | undefined, + SeriesTime: SeriesTime as string | undefined, + SeriesInstanceUID: SeriesInstanceUID as string, + SeriesNumber: String(SeriesNumber), + SeriesDescription: SeriesDescription as string | undefined, + Modality: Modality as string, + images, + }) + index++ + } return slideDisplaySets }) .filter((set): set is DisplaySet => set !== null && set !== undefined) @@ -372,12 +398,10 @@ const DicomTagBrowser = ({ matchingPaths.push(currentPath) } - if (node.children != null) { - node.children.forEach((child) => { - const childPaths = findMatchingPaths(child, currentPath) - matchingPaths = [...matchingPaths, ...childPaths] - }) - } + node.children?.forEach((child) => { + const childPaths = findMatchingPaths(child, currentPath) + matchingPaths = [...matchingPaths, ...childPaths] + }) return matchingPaths } diff --git a/src/components/DicomTagBrowser/dicomTagUtils.ts b/src/components/DicomTagBrowser/dicomTagUtils.ts index 68229c18..d9fa3f7f 100644 --- a/src/components/DicomTagBrowser/dicomTagUtils.ts +++ b/src/components/DicomTagBrowser/dicomTagUtils.ts @@ -2,6 +2,20 @@ import dcmjs from 'dcmjs' const { DicomMetaDictionary } = dcmjs.data +type DictionaryEntry = { + tag: string + vr: string + name: string +} + +type MetaDict = typeof DicomMetaDictionary & { + dictionary: Record + nameMap: Record +} + +const metaDict = DicomMetaDictionary as MetaDict +const dictionary = metaDict.dictionary + export interface TagInfo { tag: string vr: string @@ -18,104 +32,327 @@ export interface DicomTag { [key: string]: unknown } -const formatValue = (val: unknown): string => { +const PERSON_NAME_GROUP_KEYS = [ + 'Alphabetic', + 'Ideographic', + 'Phonetic', +] as const + +function isPersonNameGroupObject(val: unknown): val is Record { + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + return false + } + const o = val as Record + const keys = Object.keys(o) + if (keys.length === 0) { + return false + } + const allowed = new Set(PERSON_NAME_GROUP_KEYS) + for (const k of keys) { + if (!allowed.has(k) || typeof o[k] !== 'string') { + return false + } + } + return true +} + +/** DICOMweb JSON Person Name (PN): one or more component groups (Part 18 F.2.2). */ +function formatPersonNameGroup(o: Record): string { + const parts: string[] = [] + for (const k of PERSON_NAME_GROUP_KEYS) { + const s = o[k] + if (typeof s === 'string' && s.length > 0) { + parts.push(s) + } + } + return parts.join(' | ') +} + +/** DICOMweb JSON scalars only — avoids `String(object)` → "[object Object]". */ +function stringifyJsonScalar(val: unknown): string { + switch (typeof val) { + case 'string': + return val + case 'number': + case 'boolean': + return String(val) + case 'bigint': + return val.toString() + case 'symbol': + return val.toString() + case 'function': + return String(val) + default: + return '' + } +} + +function formatValue(val: unknown, vr?: string): string { + if (val === undefined) { + return '' + } + if (val === null) { + return 'null' + } + + const pnByVr = vr === 'PN' + const pnByShape = + (vr === undefined || vr === '') && + (isPersonNameGroupObject(val) || + (Array.isArray(val) && + val.length > 0 && + val.every((item) => isPersonNameGroupObject(item)))) + + if (pnByVr || pnByShape) { + if (Array.isArray(val)) { + return val + .map((item) => { + if (isPersonNameGroupObject(item)) { + return formatPersonNameGroup(item) + } + if (pnByVr) { + return typeof item === 'object' && item !== null + ? JSON.stringify(item) + : stringifyJsonScalar(item) + } + return formatValue(item) + }) + .join('\\') + } + if (isPersonNameGroupObject(val)) { + return formatPersonNameGroup(val) + } + } + if (typeof val === 'object' && val !== null) { return JSON.stringify(val) } - return String(val) + return stringifyJsonScalar(val) } export const formatTagValue = (tag: DicomTag): string => { - if (tag.Value == null) return '' + if (tag.Value === undefined || tag.Value === null) return '' if (Array.isArray(tag.Value)) { - return tag.Value.map(formatValue).join(', ') + return tag.Value.map((v) => formatValue(v, tag.vr)).join(', ') } - return formatValue(tag.Value) + return formatValue(tag.Value, tag.vr) +} + +/** Normalize to "(GGGG,EEEE)" for dcmjs dictionary lookup. */ +function punctuateTagId(keyword: string): string | null { + const eightHex = /^[0-9A-Fa-f]{8}$/ + if (eightHex.test(keyword)) { + const u = keyword.toUpperCase() + return `(${u.slice(0, 4)},${u.slice(4)})` + } + const punct = /^\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)$/.exec(keyword) + if (punct !== null) { + return `(${punct[1].toUpperCase()},${punct[2].toUpperCase()})` + } + return null } /** - * Processes DICOM metadata and returns a flattened array of tag information - * @param metadata - The DICOM metadata object to process - * @param depth - The current depth level for nested sequences (default: 0) - * @returns Array of processed tag information + * dicom-microscopy-viewer maps tags to keywords via its own table; newer tags + * may remain numeric keys on the dataset. nameMap is keyword-keyed only, so we + * also resolve by tag against the full dcmjs dictionary. */ -export function getRows( +function resolveDictionaryEntry(keyword: string): DictionaryEntry | undefined { + const fromName = metaDict.nameMap[keyword] as DictionaryEntry | undefined + if (fromName !== undefined) { + return fromName + } + const punct = punctuateTagId(keyword) + if (punct === null) { + return undefined + } + return dictionary[punct] +} + +function isSequenceItemArray( + value: unknown, +): value is Record[] { + if (!Array.isArray(value)) { + return false + } + return value.every( + (item) => + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + !(item instanceof Uint8Array) && + !(item instanceof Uint16Array) && + !(item instanceof Uint32Array) && + !(item instanceof Float32Array) && + !(item instanceof Float64Array), + ) +} + +function vrFromVrMap( metadata: Record, - depth = 0, -): TagInfo[] { - if (metadata === undefined || metadata === null) return [] - const keywords = Object.keys(metadata).filter((key) => key !== '_vrMap') + keyword: string, +): string { + const map = metadata._vrMap + if (map !== null && typeof map === 'object' && keyword in map) { + return String((map as Record)[keyword]) + } + return '' +} - return keywords.flatMap((keyword) => { - // @ts-expect-error - const tagInfo = DicomMetaDictionary.nameMap[keyword] as TagInfo | undefined - let value = metadata[keyword] - - // Handle private or unknown tags - if (tagInfo === undefined) { - const regex = /[0-9A-Fa-f]{6}/g - if (keyword.match(regex) == null) return [] - - return [ - { - tag: `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`, - vr: '', - keyword: 'Private Tag', - value: value?.toString() ?? '', - level: depth, - }, - ] - } +function toDisplayString(value: unknown, vr: string): string { + if (Array.isArray(value)) { + return value.map((item) => formatValue(item, vr)).join('\\') + } + if (typeof value === 'object' && value !== null) { + return formatValue(value, vr) + } + if (value === null || value === undefined) { + return '' + } + return stringifyJsonScalar(value) +} - // Handle sequence values (SQ VR) - if (tagInfo.vr === 'SQ' && value !== undefined) { - const sequenceItems = Array.isArray(value) ? value : [value] +function mapSequenceItemsToTagInfo( + sequenceItems: unknown[], + baseTag: string, + depth: number, +): TagInfo[] { + return sequenceItems.map((item, index) => { + const itemObj = + item !== null && typeof item === 'object' + ? (item as Record) + : {} + return { + tag: `${baseTag}.${index + 1}`, + vr: 'Item', + keyword: `Item ${index + 1}`, + value: `Sequence Item ${index + 1}`, + level: depth + 1, + children: getRows(itemObj, depth + 2), + } + }) +} - // Create a parent sequence node - const sequenceNode: TagInfo = { - tag: tagInfo.tag, - vr: tagInfo.vr, - keyword, +function rowsFromDictionaryEntry( + entry: DictionaryEntry, + value: unknown, + depth: number, +): TagInfo[] { + const labelKeyword = entry.name.replace(/^RETIRED_/, '') + if (entry.vr === 'SQ' && value !== undefined) { + const sequenceItems = Array.isArray(value) ? value : [value] + return [ + { + tag: entry.tag, + vr: entry.vr, + keyword: labelKeyword, value: `Sequence with ${sequenceItems.length} item(s)`, level: depth, - children: [], - } - - // Create individual nodes for each sequence item - sequenceNode.children = sequenceItems.map((item, index) => { - const itemNode: TagInfo = { - tag: `${tagInfo.tag}.${index + 1}`, - vr: 'Item', - keyword: `Item ${index + 1}`, - value: `Sequence Item ${index + 1}`, - level: depth + 1, - children: getRows(item, depth + 2), - } - return itemNode - }) - - return [sequenceNode] - } - - // Handle array values - if (Array.isArray(value)) { - value = value.map(formatValue).join('\\') - } else if (typeof value === 'object' && value !== null) { - value = formatValue(value) - } + children: mapSequenceItemsToTagInfo(sequenceItems, entry.tag, depth), + }, + ] + } + return [ + { + tag: entry.tag, + vr: entry.vr, + keyword: labelKeyword, + value: toDisplayString(value, entry.vr), + level: depth, + }, + ] +} +function rowsFromPunctuatedTag( + punct: string, + vrHint: string, + value: unknown, + depth: number, +): TagInfo[] { + if (isSequenceItemArray(value)) { return [ { - tag: tagInfo.tag, - vr: tagInfo.vr, - keyword: keyword.replace('RETIRED_', ''), - value: value?.toString() ?? '', + tag: punct, + vr: vrHint !== '' ? vrHint : 'SQ', + keyword: 'Unlisted sequence', + value: `Sequence with ${value.length} item(s)`, level: depth, + children: mapSequenceItemsToTagInfo(value, punct, depth), }, ] - }) + } + return [ + { + tag: punct, + vr: vrHint, + keyword: 'Unlisted attribute', + value: toDisplayString(value, vrHint), + level: depth, + }, + ] +} + +function rowsForUnmappedKeyword( + keyword: string, + vrHint: string, + value: unknown, + depth: number, +): TagInfo[] { + let text: string + if (value === null || value === undefined) { + text = '' + } else if (typeof value === 'object') { + text = formatValue(value, vrHint) + } else { + text = stringifyJsonScalar(value) + } + return [ + { + tag: keyword, + vr: vrHint, + keyword, + value: text, + level: depth, + }, + ] +} + +function processMetadataKeyword( + metadata: Record, + keyword: string, + depth: number, +): TagInfo[] { + const value = metadata[keyword] + const entry = resolveDictionaryEntry(keyword) + if (entry !== undefined) { + return rowsFromDictionaryEntry(entry, value, depth) + } + const punct = punctuateTagId(keyword) + const vrHint = vrFromVrMap(metadata, keyword) + if (punct !== null) { + return rowsFromPunctuatedTag(punct, vrHint, value, depth) + } + return rowsForUnmappedKeyword(keyword, vrHint, value, depth) +} + +/** + * Processes DICOM metadata and returns a flattened array of tag information + * @param metadata - The DICOM metadata object to process + * @param depth - The current depth level for nested sequences (default: 0) + * @returns Array of processed tag information + */ +export function getRows( + metadata: Record, + depth = 0, +): TagInfo[] { + if (metadata === undefined || metadata === null) return [] + const keywords = Object.keys(metadata).filter((key) => key !== '_vrMap') + + return keywords.flatMap((keyword) => + processMetadataKeyword(metadata, keyword, depth), + ) } /** diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ae954152..2e04f000 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -425,7 +425,7 @@ class Header extends React.Component { ) const showWarningCount = (warncount: number): JSX.Element => ( - + 0 ? 'green' : undefined} count={warncount} /> ) Modal.info({ @@ -614,7 +614,7 @@ class Header extends React.Component { const debugButton = ( 0 ? 'green' : undefined} count={this.state.warnings.length} style={{ zIndex: 1001 }} > diff --git a/src/components/Patient.tsx b/src/components/Patient.tsx index d134c035..67ad43f0 100644 --- a/src/components/Patient.tsx +++ b/src/components/Patient.tsx @@ -1,7 +1,13 @@ // skipcq: JS-C1003 import type * as dmv from 'dicom-microscopy-viewer' import React from 'react' -import { parseDate, parseName, parseSex } from '../utils/values' +import { + formatAdmittingDiagnoses, + formatPatientSpeciesCodeSequence, + parseDate, + parseName, + parseSex, +} from '../utils/values' import Description from './Description' interface PatientProps { @@ -15,6 +21,11 @@ interface PatientProps { */ class Patient extends React.Component> { render(): React.ReactNode { + const meta = this.props.metadata as unknown as Record + const species = formatPatientSpeciesCodeSequence( + meta.PatientSpeciesCodeSequence, + ) + const admittingDiagnosis = formatAdmittingDiagnoses(meta) const attributes = [ { name: 'ID', @@ -24,6 +35,7 @@ class Patient extends React.Component> { name: 'Name', value: parseName(this.props.metadata.PatientName), }, + ...(species !== undefined ? [{ name: 'Species', value: species }] : []), { name: 'Sex', value: parseSex(this.props.metadata.PatientSex), @@ -34,9 +46,11 @@ class Patient extends React.Component> { }, { name: 'Age', - value: (this.props.metadata as unknown as Record) - .PatientAge as string | undefined, + value: meta.PatientAge as string | undefined, }, + ...(admittingDiagnosis !== undefined + ? [{ name: 'Admitting diagnosis', value: admittingDiagnosis }] + : []), ] return } diff --git a/src/components/SlideViewer.tsx b/src/components/SlideViewer.tsx index c99b9241..b838efbf 100644 --- a/src/components/SlideViewer.tsx +++ b/src/components/SlideViewer.tsx @@ -48,6 +48,10 @@ import type { AnnotationSettings, } from '../types/annotations' import { CustomError, errorTypes } from '../utils/CustomError' +import { + applyDistinctFractionalSegmentPalettes, + applyDistinctParametricMapPalettes, +} from '../utils/distinctOverlayColormaps' import generateReport from '../utils/generateReport' import { logger } from '../utils/logger' import { withRouter } from '../utils/router' @@ -1176,6 +1180,7 @@ class SlideViewer extends React.Component { if (segmentations.length > 0) { try { this.volumeViewer.addSegments(segmentations) + applyDistinctFractionalSegmentPalettes(this.volumeViewer) resolve() } catch (error: unknown) { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1280,6 +1285,7 @@ class SlideViewer extends React.Component { if (parametricMaps.length > 0) { try { this.volumeViewer.addParameterMappings(parametricMaps) + applyDistinctParametricMapPalettes(this.volumeViewer) resolve() } catch (error: unknown) { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -2833,17 +2839,24 @@ class SlideViewer extends React.Component { })) } - /** If color is provided, create a palette color lookup table */ - let paletteColorLookupTable: dmv.color.PaletteColorLookupTable | undefined + /** + * Only pass a palette when the user changed color. Opacity-only updates + * must not send a default RGB for fractional segments or distinct + * colormaps are replaced by a flat LUT. + */ + const stylePayload: { + opacity?: number + paletteColorLookupTable?: dmv.color.PaletteColorLookupTable + } = {} + if (styleOptions.opacity !== undefined) { + stylePayload.opacity = styleOptions.opacity + } if (styleOptions.color !== undefined) { - paletteColorLookupTable = + stylePayload.paletteColorLookupTable = SlideViewer.createSegmentPaletteColorLookupTable(styleOptions.color) } - this.volumeViewer.setSegmentStyle(segmentUID, { - opacity: styleOptions.opacity, - paletteColorLookupTable, - }) + this.volumeViewer.setSegmentStyle(segmentUID, stylePayload) } /** diff --git a/src/components/SpecimenItem.tsx b/src/components/SpecimenItem.tsx index 1b6cf9a6..621833c7 100644 --- a/src/components/SpecimenItem.tsx +++ b/src/components/SpecimenItem.tsx @@ -160,17 +160,6 @@ class SpecimenItem extends React.Component< }, ) - if ( - (this.props.metadata as unknown as Record) - .AdmittingDiagnosesDescription !== undefined - ) { - attributes.push({ - name: 'Admitting Diagnoses', - value: (this.props.metadata as unknown as Record) - .AdmittingDiagnosesDescription as string, - }) - } - const uid = specimenDescription.SpecimenUID const identifier = specimenDescription.SpecimenIdentifier diff --git a/src/components/Worklist.tsx b/src/components/Worklist.tsx index 11f25d1f..43a7718e 100644 --- a/src/components/Worklist.tsx +++ b/src/components/Worklist.tsx @@ -12,6 +12,7 @@ import NotificationMiddleware, { NotificationMiddlewareContext, } from '../services/NotificationMiddleware' import { CustomError, errorTypes } from '../utils/CustomError' +import { logger } from '../utils/logger' import { type RouteComponentProps, withRouter } from '../utils/router' import { parseDate, parseName, parseSex, parseTime } from '../utils/values' @@ -20,6 +21,36 @@ const getRowKey = (record: dmv.metadata.Study): string => { return record.StudyInstanceUID } +/** True when QIDO did not return usable (0008,0061) ModalitiesInStudy. */ +function modalitiesNeedBackfill(study: dmv.metadata.Study): boolean { + const m = study.ModalitiesInStudy as string | string[] | undefined | null + if (m === undefined || m === null) { + return true + } + if (typeof m === 'string') { + return m.trim() === '' + } + if (Array.isArray(m)) { + return m.length === 0 || m.every((x) => String(x ?? '').trim() === '') + } + return true +} + +function formatModalitiesInStudyColumn( + value: string[] | string | undefined, +): string { + if (value === undefined || value === null) { + return '\u00A0' + } + if (Array.isArray(value)) { + if (value.length === 0) { + return '\u00A0' + } + return value.map(String).join(', ') + } + return String(value) +} + interface WorklistProps extends RouteComponentProps { clients: { [key: string]: DicomWebManager } } @@ -34,6 +65,9 @@ interface WorklistState { class Worklist extends React.Component { private readonly defaultPageSize = 20 + /** Bumps when a new study list replaces the table; stale modality fetches ignore results. */ + private modalitiesEnrichmentGeneration = 0 + constructor(props: WorklistProps) { super(props) this.state = { @@ -53,13 +87,16 @@ class Worklist extends React.Component { client .searchForStudies(searchOptions) .then((studies) => { + const generation = ++this.modalitiesEnrichmentGeneration + const slice = studies.slice(0, this.state.pageSize).map((study) => { + const { dataset } = dmv.metadata.formatMetadata(study) + return dataset as dmv.metadata.Study + }) this.setState({ numStudies: studies.length, - studies: studies.slice(0, this.state.pageSize).map((study) => { - const { dataset } = dmv.metadata.formatMetadata(study) - return dataset as dmv.metadata.Study - }), + studies: slice, }) + void this.runModalitiesEnrichment(client, slice, generation) }) .catch((error) => { console.error(error) @@ -122,12 +159,15 @@ class Worklist extends React.Component { client .searchForStudies(searchOptions) .then((studies) => { + const generation = ++this.modalitiesEnrichmentGeneration + const formatted = studies.map((study) => { + const { dataset } = dmv.metadata.formatMetadata(study) + return dataset as dmv.metadata.Study + }) this.setState({ - studies: studies.map((study) => { - const { dataset } = dmv.metadata.formatMetadata(study) - return dataset as dmv.metadata.Study - }), + studies: formatted, }) + void this.runModalitiesEnrichment(client, formatted, generation) }) .catch((error) => { console.error(error) @@ -141,6 +181,69 @@ class Worklist extends React.Component { }) } + private async collectModalitiesFromSeries( + client: DicomWebManager, + studyInstanceUID: string, + ): Promise { + const seriesList = await client.searchForSeries({ + studyInstanceUID, + }) + if (seriesList === null || seriesList === undefined) { + return [] + } + const modalities = new Set() + for (const raw of seriesList) { + const { dataset } = dmv.metadata.formatMetadata(raw) + const mod = (dataset as { Modality?: string }).Modality + if (mod !== undefined && mod !== null && String(mod).trim() !== '') { + modalities.add(String(mod)) + } + } + return [...modalities].sort() + } + + /** + * After the table renders from QIDO, fills ModalitiesInStudy from series + * Modality when (0008,0061) was missing. Discarded if a newer study load ran. + */ + private async runModalitiesEnrichment( + client: DicomWebManager, + studiesSnapshot: dmv.metadata.Study[], + generation: number, + ): Promise { + const need = studiesSnapshot.filter(modalitiesNeedBackfill) + if (need.length === 0) { + return + } + const byUid = await Promise.all( + need.map(async (study) => { + const uid = study.StudyInstanceUID + try { + const mods = await this.collectModalitiesFromSeries(client, uid) + return { uid, mods } + } catch { + return { uid, mods: [] as string[] } + } + }), + ) + if (generation !== this.modalitiesEnrichmentGeneration) { + return + } + const uidToMods = new Map(byUid.map(({ uid, mods }) => [uid, mods])) + this.setState((prev) => ({ + studies: prev.studies.map((study) => { + const mods = uidToMods.get(study.StudyInstanceUID) + if (mods === undefined || mods.length === 0) { + return study + } + if (!modalitiesNeedBackfill(study)) { + return study + } + return { ...study, ModalitiesInStudy: mods } + }), + })) + } + handleChange = ( pagination: TablePaginationConfig, filters: Record, @@ -156,7 +259,7 @@ class Worklist extends React.Component { } const offset = pageSize * (index - 1) const limit = pageSize - console.debug(`search for studies of page #${index}...`) + logger.debug(`search for studies of page #${index}...`) const searchCriteria: { [attribute: string]: string } = {} for (const dataIndex in filters) { const value = filters[dataIndex] @@ -291,12 +394,8 @@ class Worklist extends React.Component { { title: 'Modalities in Study', dataIndex: 'ModalitiesInStudy', - render: (value: string[] | string): string => { - if (value === undefined) { - return '\u00A0' - } - return orNbsp(String(value)) - }, + render: (value: string[] | string): string => + orNbsp(formatModalitiesInStudyColumn(value)), }, ] @@ -343,7 +442,11 @@ class Worklist extends React.Component {
{ '00201206': { vr: 'IS', Value: [1] }, '00201208': { vr: 'IS', Value: [2] }, '00080061': { vr: 'CS', Value: ['CT'] } + }, + { + '0020000D': { vr: 'UI', Value: ['1.2.3.4'] }, + '00200010': { vr: 'SH', Value: ['study4'] }, + '00080050': { vr: 'SH', Value: ['accession4'] }, + '00080020': { vr: 'DA', Value: ['20210301'] }, + '00080030': { vr: 'TM', Value: ['100000'] }, + '00100010': { vr: 'PN', Value: [{ Alphabetic: 'fourth^patient' }] }, + '00100020': { vr: 'LO', Value: ['patient4'] }, + '00100040': { vr: 'CS', Value: ['F'] }, + '00100030': { vr: 'DA' }, + '00201206': { vr: 'IS', Value: [1] }, + '00201208': { vr: 'IS', Value: [1] } + } + ] + + const seriesForBackfillStudy = [ + { + '0020000D': { vr: 'UI', Value: ['1.2.3.4'] }, + '0020000E': { vr: 'UI', Value: ['1.2.4.1'] }, + '00080060': { vr: 'CS', Value: ['OT'] } + }, + { + '0020000D': { vr: 'UI', Value: ['1.2.3.4'] }, + '0020000E': { vr: 'UI', Value: ['1.2.4.2'] }, + '00080060': { vr: 'CS', Value: ['SR'] } } ] manager.searchForStudies = async ( - options: dwc.api.SearchForStudiesOptions + _options: dwc.api.SearchForStudiesOptions ): Promise => { - return await Promise.resolve(searchResults) + return await Promise.resolve(searchResults as dwc.api.Study[]) + } + + manager.searchForSeries = async (): Promise => { + return await Promise.resolve( + seriesForBackfillStudy as unknown as dwc.api.Series[], + ) } it('should populate one row for each available study', async () => { @@ -89,8 +121,20 @@ describe('Worklist', () => { await waitFor(() => { const rows = queryAllByRole('row') - // Table has 1 header row + one body row per study; searchResults has 3 studies - expect(rows.length).toBe(4) + // Table has 1 header row + one body row per study; searchResults has 4 studies + expect(rows.length).toBe(5) + }) + }) + + it('synthesizes ModalitiesInStudy from series when study omits (0008,0061)', async () => { + const { getByText } = render( + + + + ) + + await waitFor(() => { + expect(getByText('OT, SR')).toBeTruthy() }) }) }) diff --git a/src/test/bun-preload.ts b/src/test/bun-preload.ts new file mode 100644 index 00000000..e785de67 --- /dev/null +++ b/src/test/bun-preload.ts @@ -0,0 +1,67 @@ +/** + * Registers a minimal browser-like global for `bun test` (jsdom is Jest-only here). + * Official CI still uses `npm test` / craco. + * + * happy-dom types intentionally do not match lib.dom; use loose assignments so + * this file typechecks under CRA `tsc` while Bun still runs it as preload. + */ +import { GlobalWindow } from 'happy-dom' + +const w = new GlobalWindow({ url: 'http://localhost/' }) + +const gt = globalThis as Record +gt.window = w +gt.document = w.document + +globalThis.getComputedStyle = ((element: unknown, _pseudoElt?: string | null) => + w.getComputedStyle( + element as never, + )) as unknown as typeof globalThis.getComputedStyle + +globalThis.matchMedia = w.matchMedia.bind( + w, +) as unknown as typeof globalThis.matchMedia + +/** rc-table / antd use global `Element` in `instanceof` checks */ +const domCtors = [ + 'Element', + 'HTMLElement', + 'HTMLDivElement', + 'HTMLTableElement', + 'HTMLTableSectionElement', + 'HTMLTableRowElement', + 'HTMLTableCellElement', + 'Node', + 'DocumentFragment', + 'Text', + 'Comment', + 'Event', + 'CustomEvent', + 'MouseEvent', +] as const +for (const name of domCtors) { + const ctor = (w as unknown as Record)[name] + if (typeof ctor === 'function') { + gt[name] = ctor + } +} + +if (typeof globalThis.requestAnimationFrame !== 'function') { + const handles = new Map>() + let nextRafId = 1 + globalThis.requestAnimationFrame = (cb: FrameRequestCallback): number => { + const handle = w.setTimeout(() => { + cb(Date.now()) + }, 0) + const id = nextRafId++ + handles.set(id, handle) + return id + } + globalThis.cancelAnimationFrame = (id: number): void => { + const handle = handles.get(id) + if (handle !== undefined) { + w.clearTimeout(handle) + handles.delete(id) + } + } +} diff --git a/src/utils/distinctOverlayColormaps.ts b/src/utils/distinctOverlayColormaps.ts new file mode 100644 index 00000000..69867989 --- /dev/null +++ b/src/utils/distinctOverlayColormaps.ts @@ -0,0 +1,91 @@ +// skipcq: JS-C1003 +import * as dmv from 'dicom-microscopy-viewer' + +import { getSegmentationType, getSegmentColor } from './segmentColors' + +const COLORMAP_ORDER = [ + dmv.color.ColormapNames.VIRIDIS, + dmv.color.ColormapNames.MAGMA, + dmv.color.ColormapNames.INFERNO, + dmv.color.ColormapNames.HOT, + dmv.color.ColormapNames.BLUE_RED, + dmv.color.ColormapNames.GRAY, + dmv.color.ColormapNames.PHASE, + dmv.color.ColormapNames.PORTLAND, +] as const + +function buildPaletteForName( + name: (typeof COLORMAP_ORDER)[number], +): dmv.color.PaletteColorLookupTable { + const data = dmv.color.createColormap({ name, bins: 2 ** 8 }) + return dmv.color.buildPaletteColorLookupTable({ + data, + firstValueMapped: 0, + }) +} + +/** + * When several fractional segments exist, cycle colormaps unless DICOM + * specifies a display color (Recommended Display CIELab Value). + * Requires dicom-microscopy-viewer that applies palettes for FRACTIONAL + * segments in setSegmentStyle (see fork / slim#377). + */ +export function applyDistinctFractionalSegmentPalettes( + volumeViewer: dmv.viewer.VolumeImageViewer, +): void { + const segments = volumeViewer.getAllSegments() + const fractional = segments.filter((seg) => { + const meta = volumeViewer.getSegmentMetadata(seg.uid)?.[0] as unknown as + | Record + | undefined + return getSegmentationType(meta) === 'FRACTIONAL' + }) + if (fractional.length <= 1) { + return + } + + let paletteIndex = 0 + fractional.forEach((seg) => { + const meta0 = volumeViewer.getSegmentMetadata(seg.uid)?.[0] as unknown as + | Record + | undefined + if (meta0 === undefined) { + return + } + if (getSegmentColor(meta0, seg.number) !== null) { + return + } + + const name = COLORMAP_ORDER[paletteIndex % COLORMAP_ORDER.length] + paletteIndex += 1 + const table = buildPaletteForName(name) + const style = volumeViewer.getSegmentStyle(seg.uid) + volumeViewer.setSegmentStyle(seg.uid, { + opacity: style.opacity, + paletteColorLookupTable: table, + }) + }) +} + +/** + * Distinct colormaps for multiple parametric maps (same session). + */ +export function applyDistinctParametricMapPalettes( + volumeViewer: dmv.viewer.VolumeImageViewer, +): void { + const mappings = volumeViewer.getAllParameterMappings() + if (mappings.length <= 1) { + return + } + + mappings.forEach((mapping, i) => { + const name = COLORMAP_ORDER[i % COLORMAP_ORDER.length] + const table = buildPaletteForName(name) + const style = volumeViewer.getParameterMappingStyle(mapping.uid) + volumeViewer.setParameterMappingStyle(mapping.uid, { + opacity: style.opacity, + limitValues: style.limitValues, + paletteColorLookupTable: table, + }) + }) +} diff --git a/src/utils/logger.test.ts b/src/utils/logger.test.ts index 06342678..8733fd87 100644 --- a/src/utils/logger.test.ts +++ b/src/utils/logger.test.ts @@ -4,7 +4,8 @@ import { Logger, LogLevel } from './logger' const mockWindowConfig = (config: any): void => { Object.defineProperty(window, 'config', { value: config, - writable: true + writable: true, + configurable: true, }) } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index f7bdc32b..6eda6eba 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -20,8 +20,9 @@ export class Logger { public config: LoggerConfig constructor() { - // Get logger config from global config - const globalConfig = window.config?.logger + // Get logger config from global config (browser only; Bun/Jest may run without window) + const globalConfig = + typeof window !== 'undefined' ? window.config?.logger : undefined let configLevel = 'DEBUG' if (globalConfig?.level != null && String(globalConfig.level) !== '') { configLevel = globalConfig.level as string diff --git a/src/utils/values.ts b/src/utils/values.ts index c917fbe3..8a7e86bd 100644 --- a/src/utils/values.ts +++ b/src/utils/values.ts @@ -56,4 +56,121 @@ function parseSex(value: string | null | undefined): string { return '' } -export { parseDate, parseDateTime, parseName, parseSex, parseTime } +/** + * Human-readable text for a DICOM coded concept: prefer CodeMeaning. + * Does not show SNOMED CT numeric codes (SCT) when meaning is absent. + */ +function codedConceptDisplayText(item: unknown): string { + if (item == null || typeof item !== 'object') return '' + const o = item as { + CodeValue?: string + CodeMeaning?: string + CodingSchemeDesignator?: string + } + const cm = (o.CodeMeaning ?? '').trim() + if (cm !== '') return cm + const scheme = (o.CodingSchemeDesignator ?? '').toUpperCase() + if (scheme === 'SCT') return '' + return (o.CodeValue ?? '').trim() +} + +function dedupeStringsPreserveOrder(strings: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const s of strings) { + const key = s.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(s) + } + return out +} + +/** + * (0008,1080) LO + (0008,1084) SQ (standard keywords use plural "Diagnoses"). + * Also accepts singular / legacy keys (e.g. dcmjs) for interoperability. + */ +function formatAdmittingDiagnoses( + metadata: Record, +): string | undefined { + let desc = '' + for (const key of [ + 'AdmittingDiagnosesDescription', + 'AdmittingDiagnosisDescription', + ] as const) { + const v = metadata[key] + if (typeof v === 'string' && v.trim() !== '') { + desc = v.trim() + break + } + } + + const codeSeqKeys = [ + 'AdmittingDiagnosesCodeSequence', + 'AdmittingDiagnosisCodeSequence', + 'AdmittingDiagnosisCodeSeq', + ] as const + let seq: unknown[] = [] + for (const key of codeSeqKeys) { + const v = metadata[key] + if (Array.isArray(v) && v.length > 0) { + seq = v + break + } + } + if (seq.length === 0) { + for (const key of codeSeqKeys) { + const v = metadata[key] + if (Array.isArray(v)) { + seq = v + break + } + } + } + + const codeParts: string[] = [] + for (const item of seq) { + const part = codedConceptDisplayText(item) + if (part !== '') codeParts.push(part) + } + const uniqueCodes = dedupeStringsPreserveOrder(codeParts) + const codesJoined = uniqueCodes.join(', ') + + if (desc !== '' && codesJoined !== '') { + if (desc.toLowerCase() === codesJoined.toLowerCase()) { + return desc + } + return `${desc}; ${codesJoined}` + } + if (desc !== '') return desc + if (codesJoined !== '') return codesJoined + return undefined +} + +/** (00102202) PatientSpeciesCodeSequence — meanings only; undefined if absent or empty. */ +function formatPatientSpeciesCodeSequence( + sequence: unknown, +): string | undefined { + if (!Array.isArray(sequence) || sequence.length === 0) { + return undefined + } + const parts: string[] = [] + for (const item of sequence) { + const part = codedConceptDisplayText(item) + if (part !== '') parts.push(part) + } + const unique = dedupeStringsPreserveOrder(parts) + return unique.length > 0 ? unique.join(', ') : undefined +} + +export { + codedConceptDisplayText, + dedupeStringsPreserveOrder, + formatAdmittingDiagnoses, + formatPatientSpeciesCodeSequence, + parseDate, + parseDateTime, + parseName, + parseSex, + parseTime, +} diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 99daa73d..47a29b64 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -183,15 +183,18 @@ declare module 'dicom-microscopy-viewer' { mappingUID: string, styleOptions: { opacity?: number + limitValues?: number[] paletteColorLookupTable?: color.PaletteColorLookupTable } ): void getParameterMappingDefaultStyle (mappingUID: string): { opacity: number + limitValues: number[] paletteColorLookupTable: color.PaletteColorLookupTable } getParameterMappingStyle (mappingUID: string): { opacity: number + limitValues: number[] paletteColorLookupTable: color.PaletteColorLookupTable } isParameterMappingVisible (mappingUID: string): boolean @@ -784,6 +787,22 @@ declare module 'dicom-microscopy-viewer' { } declare namespace color { + export const ColormapNames: { + readonly VIRIDIS: string + readonly INFERNO: string + readonly MAGMA: string + readonly GRAY: string + readonly BLUE_RED: string + readonly PHASE: string + readonly PORTLAND: string + readonly HOT: string + } + + export function createColormap (options: { + name: string + bins: number + }): number[][] + export interface PaletteColorLookupTableOptions { uid: string redDescriptor: number[]