diff --git a/.gitignore b/.gitignore index d196eb0b26..a5d419d183 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ __MACOSX # custom .next out/ +# hunkwise +.vscode/hunkwise/ diff --git a/__mocks__/@hms-dbmi/viv.js b/__mocks__/@hms-dbmi/viv.js index 656e63ccfc..dc7c3131b7 100644 --- a/__mocks__/@hms-dbmi/viv.js +++ b/__mocks__/@hms-dbmi/viv.js @@ -1,3 +1,29 @@ +// Minimal viv stubs for jsdom tests. The real layers need WebGL; tests only need +// the imports to resolve (BitmaskLayer extends XRLayer; SpatialViewer constructs +// MultiscaleImageLayer and reads getDefaultInitialViewState). +class StubLayer { + constructor(props) { + this.props = props; + } + + // lifecycle methods invoked via super.* by subclasses — no-ops in tests + // eslint-disable-next-line class-methods-use-this + updateState() {} + + // eslint-disable-next-line class-methods-use-this + finalizeState() {} + + // eslint-disable-next-line class-methods-use-this + draw() {} +} +StubLayer.defaultProps = {}; + +class XRLayer extends StubLayer {} +class MultiscaleImageLayer extends StubLayer {} + module.exports = { ZarrPixelSource: jest.fn(), + XRLayer, + MultiscaleImageLayer, + getDefaultInitialViewState: jest.fn(() => ({ target: [0, 0, 0], zoom: 0 })), }; diff --git a/package-lock.json b/package-lock.json index 4395809972..16642afc4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "@deck.gl/core": "^8.8.27", "@deck.gl/layers": "^8.8.27", "@hms-dbmi/viv": "^0.16.1", + "@luma.gl/constants": "^8.5.21", + "@luma.gl/core": "^8.5.21", "@nebula.gl/edit-modes": "^0.23.8", "@nebula.gl/layers": "^0.23.8", "@next/bundle-analyzer": "^14.0.3", @@ -37,10 +39,9 @@ "@turf/helpers": "^7.3.4", "@vitessce/heatmap": "^3.9.8", "@vitessce/icons": "^3.9.8", - "@vitessce/scatterplot": "^3.9.8", - "@vitessce/spatial": "^3.9.8", "@vitessce/styles": "^3.9.8", "antd": "^4.24.8", + "apache-arrow": "^21.1.0", "aws-amplify": "^5.3.11", "axios": "^1.6.2", "babel-plugin-add-module-exports": "^1.0.4", @@ -9218,10 +9219,14 @@ }, "node_modules/@luma.gl/constants": { "version": "8.5.21", + "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-8.5.21.tgz", + "integrity": "sha512-aJxayGxTT+IRd1vfpcgD/cKSCiVJjBNiuiChS96VulrmCvkzUOLvYXr42y5qKB4RyR7vOIda5uQprNzoHrhQAA==", "license": "MIT" }, "node_modules/@luma.gl/core": { "version": "8.5.21", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-8.5.21.tgz", + "integrity": "sha512-11jQJQEMoR/IN2oIsd4zFxiQJk6FE+xgVIMUcsCTBuzafTtQZ8Po9df8mt+MVewpDyBlTVs6g8nxHRH4np1ukA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", @@ -13732,6 +13737,18 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/command-line-args": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", + "integrity": "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==", + "license": "MIT" + }, + "node_modules/@types/command-line-usage": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/command-line-usage/-/command-line-usage-5.0.4.tgz", + "integrity": "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==", + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "license": "MIT" @@ -13846,10 +13863,12 @@ } }, "node_modules/@types/node": { - "version": "20.9.0", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~7.18.0" } }, "node_modules/@types/node-fetch": { @@ -14231,31 +14250,6 @@ "zod": "^3.21.4" } }, - "node_modules/@vitessce/scatterplot": { - "version": "3.9.8", - "license": "MIT", - "dependencies": { - "@vitessce/constants-internal": "3.9.8", - "@vitessce/gl": "3.9.8", - "@vitessce/icons": "3.9.8", - "@vitessce/styles": "3.9.8", - "@vitessce/tooltip": "3.9.8", - "@vitessce/utils": "3.9.8", - "@vitessce/vit-s": "3.9.8", - "clsx": "^1.1.1", - "d3-force": "^2.1.1", - "d3-quadtree": "^1.0.7", - "lodash-es": "^4.17.21", - "react-aria": "^3.28.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@vitessce/scatterplot/node_modules/d3-quadtree": { - "version": "1.0.7", - "license": "BSD-3-Clause" - }, "node_modules/@vitessce/schemas": { "version": "3.9.8", "license": "MIT", @@ -14364,32 +14358,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@vitessce/spatial": { - "version": "3.9.8", - "license": "MIT", - "dependencies": { - "@vitessce/constants-internal": "3.9.8", - "@vitessce/gl": "3.9.8", - "@vitessce/globals": "3.9.8", - "@vitessce/legend": "3.9.8", - "@vitessce/scatterplot": "3.9.8", - "@vitessce/sets-utils": "3.9.8", - "@vitessce/spatial-utils": "3.9.8", - "@vitessce/styles": "3.9.8", - "@vitessce/tooltip": "3.9.8", - "@vitessce/utils": "3.9.8", - "@vitessce/vit-s": "3.9.8", - "d3-array": "^2.4.0", - "lodash-es": "^4.17.21", - "math.gl": "^3.5.6", - "mathjs": "^9.2.0", - "react-aria": "^3.28.0", - "short-number": "^1.0.6" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@vitessce/spatial-utils": { "version": "3.9.8", "license": "MIT", @@ -14455,27 +14423,6 @@ "numcodecs": "^0.3.2" } }, - "node_modules/@vitessce/spatial/node_modules/mathjs": { - "version": "9.5.2", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.15.4", - "complex.js": "^2.0.15", - "decimal.js": "^10.3.1", - "escape-latex": "^1.2.0", - "fraction.js": "^4.1.1", - "javascript-natural-sort": "^0.7.1", - "seedrandom": "^3.0.5", - "tiny-emitter": "^2.1.0", - "typed-function": "^2.0.0" - }, - "bin": { - "mathjs": "bin/cli.js" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/@vitessce/styles": { "version": "3.9.8", "license": "MIT", @@ -15231,6 +15178,32 @@ "node": ">= 8" } }, + "node_modules/apache-arrow": { + "version": "21.1.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.1.0.tgz", + "integrity": "sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/command-line-args": "^5.2.3", + "@types/command-line-usage": "^5.0.4", + "@types/node": "^24.0.3", + "command-line-args": "^6.0.1", + "command-line-usage": "^7.0.1", + "flatbuffers": "^25.1.24", + "json-bignum": "^0.0.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, + "node_modules/apache-arrow/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/argparse": { "version": "1.0.10", "license": "MIT", @@ -15246,6 +15219,15 @@ "dequal": "^2.0.3" } }, + "node_modules/array-back": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.3.tgz", + "integrity": "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.0", "dev": true, @@ -16179,6 +16161,91 @@ "node": ">=4" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/chalk-template/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/chalk-template/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk-template/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/char-regex": { "version": "1.0.2", "dev": true, @@ -16472,6 +16539,44 @@ "node": ">= 0.8" } }, + "node_modules/command-line-args": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-6.0.2.tgz", + "integrity": "sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.3", + "find-replace": "^5.0.2", + "lodash.camelcase": "^4.3.0", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, + "node_modules/command-line-usage": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-7.0.4.tgz", + "integrity": "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "chalk-template": "^0.4.0", + "table-layout": "^4.1.1", + "typical": "^7.3.0" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/commander": { "version": "2.20.3", "license": "MIT" @@ -16887,19 +16992,6 @@ "tsv2json": "bin/dsv2json" } }, - "node_modules/d3-force": { - "version": "2.1.1", - "license": "BSD-3-Clause", - "dependencies": { - "d3-dispatch": "1 - 2", - "d3-quadtree": "1 - 2", - "d3-timer": "1 - 2" - } - }, - "node_modules/d3-force/node_modules/d3-quadtree": { - "version": "2.0.0", - "license": "BSD-3-Clause" - }, "node_modules/d3-format": { "version": "3.1.0", "license": "ISC", @@ -18812,6 +18904,23 @@ "react-dom": "*" } }, + "node_modules/find-replace": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-5.0.2.tgz", + "integrity": "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@75lb/nature": "latest" + }, + "peerDependenciesMeta": { + "@75lb/nature": { + "optional": true + } + } + }, "node_modules/find-root": { "version": "1.1.0", "license": "MIT" @@ -18841,6 +18950,12 @@ "node": ">=4" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/flatted": { "version": "2.0.2", "dev": true, @@ -22406,6 +22521,14 @@ "node": ">=6" } }, + "node_modules/json-bignum": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", + "integrity": "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "license": "MIT" @@ -23020,6 +23143,12 @@ "version": "4.17.21", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "license": "MIT" @@ -26732,10 +26861,6 @@ "node": ">=8" } }, - "node_modules/short-number": { - "version": "1.0.7", - "license": "MIT" - }, "node_modules/side-channel": { "version": "1.0.6", "license": "MIT", @@ -27269,6 +27394,19 @@ "node": ">=6.0.0" } }, + "node_modules/table-layout": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz", + "integrity": "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==", + "license": "MIT", + "dependencies": { + "array-back": "^6.2.2", + "wordwrapjs": "^5.1.0" + }, + "engines": { + "node": ">=12.17" + } + }, "node_modules/table/node_modules/ansi-regex": { "version": "4.1.1", "dev": true, @@ -27807,6 +27945,15 @@ "node": ">=4.2.0" } }, + "node_modules/typical": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-7.3.0.tgz", + "integrity": "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/uglify-js": { "version": "3.0.27", "license": "BSD-2-Clause", @@ -27862,7 +28009,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unfetch": { @@ -29108,6 +29257,15 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "license": "MIT", diff --git a/package.json b/package.json index 6cf8e9488e..1c6524a6f9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ] }, "scripts": { - "start": "NODE_ENV=development node server.js", + "start": "NODE_ENV=development node --max-old-space-size=8192 server.js", "build": "next build", "prod": "NODE_ENV=production node server.js", "pretest": "eslint ./src", @@ -58,6 +58,8 @@ "@deck.gl/core": "^8.8.27", "@deck.gl/layers": "^8.8.27", "@hms-dbmi/viv": "^0.16.1", + "@luma.gl/constants": "^8.5.21", + "@luma.gl/core": "^8.5.21", "@nebula.gl/edit-modes": "^0.23.8", "@nebula.gl/layers": "^0.23.8", "@next/bundle-analyzer": "^14.0.3", @@ -69,10 +71,9 @@ "@turf/helpers": "^7.3.4", "@vitessce/heatmap": "^3.9.8", "@vitessce/icons": "^3.9.8", - "@vitessce/scatterplot": "^3.9.8", - "@vitessce/spatial": "^3.9.8", "@vitessce/styles": "^3.9.8", "antd": "^4.24.8", + "apache-arrow": "^21.1.0", "aws-amplify": "^5.3.11", "axios": "^1.6.2", "babel-plugin-add-module-exports": "^1.0.4", diff --git a/public/static/media/spatialMolecules.png b/public/static/media/spatialMolecules.png new file mode 100644 index 0000000000..5559d2d469 Binary files /dev/null and b/public/static/media/spatialMolecules.png differ diff --git a/src/__test__/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.test.jsx b/src/__test__/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.test.jsx index 202f0cf471..f57ffd9788 100644 --- a/src/__test__/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.test.jsx +++ b/src/__test__/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.test.jsx @@ -5,6 +5,8 @@ import { import { Provider } from 'react-redux'; import { makeStore } from 'redux/store'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; import userEvent from '@testing-library/user-event'; import SubsetCellSetsOperation from 'components/data-exploration/cell-sets-tool/SubsetCellSetsOperation'; @@ -112,3 +114,35 @@ describe('SubsetCellSetsOperation', () => { expect(mockOnCreate).not.toHaveBeenCalled(); }); }); + +describe('SubsetCellSetsOperation — disabled for spatial technologies', () => { + const mockStore = configureMockStore([thunk]); + + const renderWithTech = (type) => { + const sampleId = 'sample-0'; + const store = mockStore({ + experimentSettings: { info: { sampleIds: [sampleId], experimentName: 'exp' } }, + samples: { [sampleId]: { id: sampleId, type } }, + }); + + render( + + + , + ); + }; + + it.each(['visium_hd', 'xenium'])('disables the Subset button for spatial tech %s', (type) => { + renderWithTech(type); + + const button = screen.getByLabelText(/Create new experiment from selected cellsets/i); + expect(button).toBeDisabled(); + }); + + it('keeps the Subset button enabled for a non-spatial tech (10x)', () => { + renderWithTech('10x'); + + const button = screen.getByLabelText(/Create new experiment from selected cellsets/i); + expect(button).not.toBeDisabled(); + }); +}); diff --git a/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx b/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx index f03e411dcf..71718005d7 100644 --- a/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx +++ b/src/__test__/components/data-exploration/embedding/SpatialViewer.test.jsx @@ -17,6 +17,7 @@ import PipelineStatus from 'utils/pipelineStatusValues'; import '__test__/test-utils/setupTests'; import ExpressionMatrix from 'utils/ExpressionMatrix/ExpressionMatrix'; import ZipFileStore from 'components/data-exploration/spatial/ZipFileStore'; +import { loadMoleculeMeta } from 'utils/spatial/loadMoleculeNodes'; jest.mock('utils/data-management/downloadSampleFile', () => ({ getSampleFileUrls: jest.fn(), @@ -34,6 +35,17 @@ jest.mock('components/data-exploration/spatial/ZipFileStore', () => ({ fromUrl: jest.fn(), })); +jest.mock('utils/spatial/loadMoleculeNodes', () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve({ + x: new Float32Array(0), y: new Float32Array(0), featureCode: new Int32Array(0), count: 0, + })), + loadMoleculeMeta: jest.fn(() => Promise.resolve({ + maxDepth: 0, + genes: [{ code: 0, gene: 'Gad1', color: '#1f77b4' }], + })), +})); + const mockStore = configureMockStore([thunk]); let component; let store; @@ -45,80 +57,80 @@ const obj2sSampleId = 'obj2s-sample'; const sample1FileId = 'sample1'; const sample2FileId = 'sample2'; -describe('SpatialViewer', () => { - const initialState = { - componentConfig: { - ...initialComponentConfigStates, - }, - backendStatus: { - [experimentId]: { - status: { - obj2s: { - shouldRerun: false, - status: PipelineStatus.SUCCEEDED, +const initialState = { + componentConfig: { + ...initialComponentConfigStates, + }, + backendStatus: { + [experimentId]: { + status: { + obj2s: { + shouldRerun: false, + status: PipelineStatus.SUCCEEDED, - }, }, }, }, - embeddings: { - images: { - ...initialEmbeddingState, - loading: false, - data: [[-13, 32], [6, 7], [43, 9], [57, 3]], - }, + }, + embeddings: { + images: { + ...initialEmbeddingState, + loading: false, + data: [[-13, 32], [6, 7], [43, 9], [57, 3]], }, - cellSets: { - properties: { - louvain: { - name: 'Louvain clusters', - color: undefined, - }, - cluster1: { - color: '#0000ff', - cellIds: new Set([0, 3]), - }, - cluster2: { - color: '#ff0000', - cellIds: new Set([1, 2]), - }, - [sample1FileId]: { - cellIds: new Set([0, 1]), - }, - [sample2FileId]: { - cellIds: new Set([2, 3]), - }, + }, + cellSets: { + properties: { + louvain: { + name: 'Louvain clusters', + color: undefined, }, - hierarchy: [ - { - key: 'louvain', - children: [{ key: 'cluster1' }, { key: 'cluster2' }], - }, - ], - hidden: new Set(), - }, - genes: { - expression: { - full: { - loading: false, - matrix: new ExpressionMatrix(), - }, + cluster1: { + color: '#0000ff', + cellIds: new Set([0, 3]), + }, + cluster2: { + color: '#ff0000', + cellIds: new Set([1, 2]), + }, + [sample1FileId]: { + cellIds: new Set([0, 1]), + }, + [sample2FileId]: { + cellIds: new Set([2, 3]), }, }, - cellInfo: { - cellId: 2, - focus: { - store: 'cellSets', + hierarchy: [ + { key: 'louvain', + children: [{ key: 'cluster1' }, { key: 'cluster2' }], }, - }, - experimentSettings: { - info: { - sampleIds: [obj2sSampleId], + ], + hidden: new Set(), + }, + genes: { + expression: { + full: { + loading: false, + matrix: new ExpressionMatrix(), }, }, - }; + }, + cellInfo: { + cellId: 2, + focus: { + store: 'cellSets', + key: 'louvain', + }, + }, + experimentSettings: { + info: { + sampleIds: [obj2sSampleId], + }, + }, +}; +describe('SpatialViewer', () => { beforeAll(async () => { await preloadAll(); }); @@ -145,15 +157,18 @@ describe('SpatialViewer', () => { }); it('fetches sample file URLs and processes them', () => { - // gets sample file urls from single obj2s sample id - expect(getSampleFileUrls).toHaveBeenCalledTimes(1); + // fetches the image (ome_zarr_zip), segmentation (segmentations_ome_zarr_zip) + // and optional molecules (molecules_by_gene) file URLs for the obj2s sample + const requestedFileTypes = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requestedFileTypes).toEqual( + expect.arrayContaining(['ome_zarr_zip', 'segmentations_ome_zarr_zip', 'molecules_by_gene']), + ); - // creates zip file store and zarrRoot from each of the two returned urls - expect(ZipFileStore.fromUrl).toHaveBeenCalledTimes(2); - expect(zarrRoot).toHaveBeenCalledTimes(2); + // a zip store + zarr root per returned url, for both the image and segmentation grids + expect(zarrRoot).toHaveBeenCalledTimes(4); - // loads a single grid from the two roots - expect(loadOmeZarrGrid).toHaveBeenCalledTimes(1); + // loads a grid for the image and a grid for the segmentations + expect(loadOmeZarrGrid).toHaveBeenCalledTimes(2); }); it('renders correctly with initial data', () => { @@ -164,3 +179,159 @@ describe('SpatialViewer', () => { expect(component.find(CellInfo).length).toEqual(0); }); }); + +describe('SpatialViewer — imageless technology (e.g. Xenium)', () => { + const xeniumSampleId = 'xenium-sample'; + + const imagelessState = { + ...initialState, + samples: { + [xeniumSampleId]: { id: xeniumSampleId, type: 'xenium' }, + }, + cellSets: { + ...initialState.cellSets, + properties: { + ...initialState.cellSets.properties, + [xeniumSampleId]: { cellIds: new Set([0, 1, 2, 3]) }, + }, + }, + experimentSettings: { + info: { sampleIds: [xeniumSampleId] }, + }, + }; + + beforeAll(async () => { + await preloadAll(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + getSampleFileUrls.mockResolvedValue([ + { url: 'http://example.com/segmentations.ome.zarr.zip', fileId: xeniumSampleId }, + ]); + + store = mockStore(imagelessState); + + await act(() => { + component = mount( + + + , + ); + }); + }); + + afterEach(() => { + component.unmount(); + }); + + it('skips the tissue-image (ome_zarr_zip) fetch entirely', () => { + const requestedFileTypes = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requestedFileTypes).not.toContain('ome_zarr_zip'); + }); + + it('still fetches the segmentation OME-Zarr', () => { + const requestedFileTypes = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requestedFileTypes).toContain('segmentations_ome_zarr_zip'); + }); + + it('renders without an image loader (does not crash on the absent image)', () => { + expect(component.find('SpatialViewer').length).toEqual(1); + }); +}); + +describe('SpatialViewer — molecule (transcript) overlay', () => { + const xeniumSampleId = 'xenium-sample'; + + const baseMoleculeState = { + ...initialState, + samples: { + [xeniumSampleId]: { id: xeniumSampleId, type: 'xenium' }, + }, + cellSets: { + ...initialState.cellSets, + properties: { + ...initialState.cellSets.properties, + [xeniumSampleId]: { cellIds: new Set([0, 1, 2, 3]) }, + }, + }, + experimentSettings: { + info: { sampleIds: [xeniumSampleId] }, + }, + }; + + // Resolve a distinct payload per requested file type so the molecules_by_gene + // URL is real (not shared with the segmentation URL). + const mockUrlsByType = (hasMolecules) => { + getSampleFileUrls.mockImplementation((_e, _s, fileType) => { + if (fileType === 'molecules_by_gene') { + return hasMolecules + ? Promise.resolve([{ url: 'http://example.com/molecules.bygene.zip', fileId: xeniumSampleId }]) + : Promise.reject(new Error('404')); + } + return Promise.resolve([ + { url: 'http://example.com/segmentations.ome.zarr.zip', fileId: xeniumSampleId }, + ]); + }); + }; + + beforeAll(async () => { await preloadAll(); }); + afterEach(() => { component.unmount(); }); + + const mountWith = async (state) => { + store = mockStore(state); + await act(() => { + component = mount( + + + , + ); + }); + }; + + it('does not build a molecule store when the sample has no molecule artifact', async () => { + jest.clearAllMocks(); + mockUrlsByType(false); + ZipFileStore.fromUrl.mockReturnValue({ get: jest.fn() }); + await mountWith({ + ...baseMoleculeState, + componentConfig: { + ...initialComponentConfigStates, + interactiveSpatial: { + ...initialComponentConfigStates.interactiveSpatial, + config: { + ...initialComponentConfigStates.interactiveSpatial.config, + showMolecules: true, + }, + }, + }, + }); + // molecule fetch rejected => no molecule artifact store/meta loaded + expect(loadMoleculeMeta).not.toHaveBeenCalled(); + }); + + it('builds a molecule store + loads meta when a molecule artifact is present', async () => { + jest.clearAllMocks(); + mockUrlsByType(true); + ZipFileStore.fromUrl.mockReturnValue({ get: jest.fn() }); + await mountWith({ + ...baseMoleculeState, + componentConfig: { + ...initialComponentConfigStates, + interactiveSpatial: { + ...initialComponentConfigStates.interactiveSpatial, + config: { + ...initialComponentConfigStates.interactiveSpatial.config, + showMolecules: true, + }, + }, + }, + }); + + const moleculeStoreUrls = ZipFileStore.fromUrl.mock.calls + .map(([url]) => url) + .filter((url) => url.includes('molecules.bygene.zip')); + expect(moleculeStoreUrls).toHaveLength(1); + expect(loadMoleculeMeta).toHaveBeenCalled(); + }); +}); diff --git a/src/__test__/components/data-exploration/spatial/SpatialVisibleLayersSettings.test.jsx b/src/__test__/components/data-exploration/spatial/SpatialVisibleLayersSettings.test.jsx new file mode 100644 index 0000000000..082811b822 --- /dev/null +++ b/src/__test__/components/data-exploration/spatial/SpatialVisibleLayersSettings.test.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { mount } from 'enzyme'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import SpatialVisibleLayersSettings from 'components/data-exploration/spatial/SpatialVisibleLayersSettings'; +import { sampleTech } from 'utils/constants'; +import '__test__/test-utils/setupTests'; + +const mockStore = configureMockStore([thunk]); +const componentType = 'interactiveSpatial'; +const sampleId = 'sample-1'; + +const makeStore = (technology, configOverrides = {}) => mockStore({ + componentConfig: { + [componentType]: { + config: { + showImages: true, + showSegmentations: true, + showSegmentationOutlines: false, + showMolecules: false, + ...configOverrides, + }, + }, + }, + experimentSettings: { info: { sampleIds: [sampleId] } }, + samples: { [sampleId]: { type: technology } }, +}); + +const mountWithConfig = (technology, configOverrides) => mount( + + + , +); + +const mountWith = (technology) => mount( + + + , +); + +describe('SpatialVisibleLayersSettings', () => { + it('shows the Images toggle for image-backed techs (Visium HD)', () => { + const component = mountWith(sampleTech.VISIUM_HD); + const text = component.text(); + expect(text).toContain('Images'); + expect(text).toContain('Segmentations'); + component.unmount(); + }); + + it('hides the Images toggle for imageless techs (Xenium)', () => { + const component = mountWith(sampleTech.XENIUM); + const text = component.text(); + expect(text).not.toContain('Images'); + // the other layers are still offered + expect(text).toContain('Segmentations'); + expect(text).toContain('Outlines'); + component.unmount(); + }); + + it('offers the Molecules toggle for Xenium', () => { + const component = mountWith(sampleTech.XENIUM); + expect(component.text()).toContain('Molecules'); + component.unmount(); + }); + + it('does not offer the Molecules toggle for image-backed techs (Visium HD)', () => { + const component = mountWith(sampleTech.VISIUM_HD); + expect(component.text()).not.toContain('Molecules'); + component.unmount(); + }); + + it('exposes only a Molecules toggle — no point-size/opacity/gene controls', () => { + // The overlay shows the single gene currently being plotted, in one colour, + // so there are no per-layer molecule controls in either toggle state. + const off = mountWith(sampleTech.XENIUM); + expect(off.text()).not.toContain('Point size'); + expect(off.text()).not.toContain('Opacity'); + off.unmount(); + + const on = mountWithConfig(sampleTech.XENIUM, { showMolecules: true }); + expect(on.text()).not.toContain('Point size'); + expect(on.text()).not.toContain('Opacity'); + expect(on.text()).toContain('Molecules'); + on.unmount(); + }); +}); diff --git a/src/__test__/components/data-processing/genes-vs-umis/GenesVsUMIs.test.jsx b/src/__test__/components/data-processing/genes-vs-umis/GenesVsUMIs.test.jsx index 394dc7695a..c82d944f0c 100644 --- a/src/__test__/components/data-processing/genes-vs-umis/GenesVsUMIs.test.jsx +++ b/src/__test__/components/data-processing/genes-vs-umis/GenesVsUMIs.test.jsx @@ -98,7 +98,7 @@ describe('GenesVsUMIs', () => { expect(screen.queryByText('Filtering Settings')).toBeInTheDocument(); expect(screen.queryByText(/Results will appear here/i)).toBeInTheDocument(); expect(screen.queryByTestId('vega-container')).not.toBeInTheDocument(); - expect(screen.queryByText('Estimated number of cells')).not.toBeInTheDocument(); + expect(screen.queryByText('Number of cells')).not.toBeInTheDocument(); }); it('Shows plot with data', () => { @@ -114,7 +114,7 @@ describe('GenesVsUMIs', () => { // Quering by test id because canvases are note created with tests expect(screen.queryByText('Filtering Settings')).toBeInTheDocument(); expect(screen.queryAllByTestId('vega-container').length).toEqual(PLOTS_PER_SAMPLE); - expect(screen.queryAllByText('Estimated number of cells').length).toEqual(PLOTS_PER_SAMPLE); + expect(screen.queryAllByText('Number of cells').length).toEqual(PLOTS_PER_SAMPLE); expect(screen.queryByText(/Results will appear here/i)).not.toBeInTheDocument(); }); }); diff --git a/src/__test__/components/plots/SpatialCategoricalAndFeaturePlot.test.jsx b/src/__test__/components/plots/SpatialCategoricalAndFeaturePlot.test.jsx new file mode 100644 index 0000000000..2433a07190 --- /dev/null +++ b/src/__test__/components/plots/SpatialCategoricalAndFeaturePlot.test.jsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { act } from 'react-dom/test-utils'; +import { render } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import SpatialCategoricalPlot from 'components/plots/SpatialCategoricalPlot'; +import SpatialFeaturePlot from 'components/plots/SpatialFeaturePlot'; +import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; + +jest.mock('utils/data-management/downloadSampleFile', () => ({ + getSampleFileUrls: jest.fn(), +})); + +// Rendering / streaming deps are irrelevant to the URL-fetch + sample-default logic. +jest.mock('react-vega', () => ({ Vega: () => null })); +jest.mock('vega-webgl-renderer', () => ({}), { virtual: true }); + +jest.mock('components/plots/useSpatialStream', () => ({ + __esModule: true, + default: jest.fn(() => ({ + imageDims: null, + segmentationsAvailable: false, + segProbeDone: true, + tissueImageData: null, + segOverlayData: null, + onViewportChange: jest.fn(), + ready: false, + })), +})); + +jest.mock('components/plots/usePreventWheelScroll', () => ({ + __esModule: true, + default: () => ({ current: null }), +})); + +// Redux thunks dispatched on mount: no-op them so they don't hit the API. +jest.mock('redux/actions/cellSets', () => ({ loadCellSets: () => ({ type: 'TEST/loadCellSets' }) })); +jest.mock('redux/actions/embedding', () => ({ loadEmbedding: () => ({ type: 'TEST/loadEmbedding' }) })); +jest.mock('redux/actions/experimentSettings', () => ({ loadProcessingSettings: () => ({ type: 'TEST/loadProcessingSettings' }) })); + +// Return a STABLE reference: a fresh object per call makes useSelector re-render +// on every mockStore dispatch, causing an infinite render/dispatch loop. +jest.mock('redux/selectors', () => { + const cellSetsStub = { accessible: false, hierarchy: [], properties: {} }; + return { getCellSets: () => () => cellSetsStub }; +}); + +jest.mock('utils/plotSpecs/generateSpatialCategoricalSpec', () => ({ + generateSpec: jest.fn(() => ({})), + generateData: jest.fn(() => []), + filterCells: jest.fn(() => ({ filteredCells: {} })), +})); + +jest.mock('utils/plotSpecs/generateSpatialFeatureSpec', () => ({ + generateSpec: jest.fn(() => ({})), + generateData: jest.fn(() => []), + filterCells: jest.fn(() => ({ filteredCells: {}, filteredCellIds: new Set() })), +})); + +const mockStore = configureMockStore([thunk]); + +const experimentId = 'exp-1'; +const sampleId = 'xenium-sample'; + +const config = { + dimensions: { width: 100, height: 100 }, + colour: { gradient: 'default', toggleInvert: '#FFFFFF', masterColour: '#000000' }, + marker: { opacity: 10, outline: false }, + legend: { enabled: true }, + selectedSample: null, + selectedCellSet: 'louvain', +}; + +const buildState = (technology) => ({ + embeddings: { images: { data: [[1, 2]], loading: false, error: false } }, + experimentSettings: { + info: { sampleIds: [sampleId] }, + originalProcessing: { configureEmbedding: { embeddingSettings: {} } }, + }, + backendStatus: { [experimentId]: { status: { obj2s: { status: 'NOT_CREATED' } } } }, + samples: { [sampleId]: { id: sampleId, type: technology } }, + genes: { expression: { full: { loading: false, matrix: {} } } }, +}); + +const Components = [ + ['SpatialCategoricalPlot', SpatialCategoricalPlot], + ['SpatialFeaturePlot', SpatialFeaturePlot], +]; + +describe.each(Components)('%s — imageless technology (e.g. Xenium)', (_name, Component) => { + let onSampleDefault; + + const renderPlot = async (technology) => { + onSampleDefault = jest.fn(); + const store = mockStore(buildState(technology)); + await act(async () => { + render( + + + , + ); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + getSampleFileUrls.mockResolvedValue([{ url: 'http://example.com/seg.zarr.zip', fileId: sampleId }]); + }); + + it('skips the tissue-image (ome_zarr_zip) fetch for an imageless tech', async () => { + await renderPlot('xenium'); + + const requested = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requested).not.toContain('ome_zarr_zip'); + }); + + it('still fetches the segmentation OME-Zarr', async () => { + await renderPlot('xenium'); + + const requested = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requested).toContain('segmentations_ome_zarr_zip'); + }); + + it('initialises the selected sample (no infinite spinner) using the registered sample id', async () => { + await renderPlot('xenium'); + + expect(onSampleDefault).toHaveBeenCalledWith(sampleId); + }); + + it('does fetch the tissue image for a non-imageless spatial tech (visium_hd)', async () => { + await renderPlot('visium_hd'); + + const requested = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requested).toContain('ome_zarr_zip'); + }); +}); diff --git a/src/__test__/components/plots/SpatialMoleculePlot.test.jsx b/src/__test__/components/plots/SpatialMoleculePlot.test.jsx new file mode 100644 index 0000000000..a80327417c --- /dev/null +++ b/src/__test__/components/plots/SpatialMoleculePlot.test.jsx @@ -0,0 +1,301 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { act } from 'react-dom/test-utils'; +import { render, screen, waitFor } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import SpatialMoleculePlot from 'components/plots/SpatialMoleculePlot'; +import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; +import loadMoleculeNodes, { loadMoleculeMeta } from 'utils/spatial/loadMoleculeNodes'; +import { MOLECULE_PALETTE } from 'utils/spatial/moleculeColors'; +import { loadOmeZarrGrid } from 'components/data-exploration/spatial/loadOmeZarr'; + +// capture the props deck.gl is rendered with (layers, viewState, handlers) +let deckProps = {}; +jest.mock('next/dynamic', () => () => (props) => { + deckProps = props; + return
; +}); + +jest.mock('utils/data-management/downloadSampleFile', () => ({ + getSampleFileUrls: jest.fn(), +})); + +jest.mock('utils/spatial/loadMoleculeNodes', () => ({ + __esModule: true, + default: jest.fn(), + loadMoleculeMeta: jest.fn(), +})); + +jest.mock('components/data-exploration/spatial/loadOmeZarr', () => ({ + loadOmeZarrGrid: jest.fn(), +})); + +// reuse a simple filterCells stub — the grey LUT just needs a Set of cell ids +jest.mock('utils/plotSpecs/generateSpatialFeatureSpec', () => ({ + filterCells: jest.fn(() => new Set([1, 2])), +})); + +jest.mock('redux/actions/cellSets', () => ({ + loadCellSets: jest.fn(() => ({ type: 'cellSets/loading' })), +})); + +jest.mock('redux/actions/genes/loadGeneList', () => ({ + __esModule: true, + default: jest.fn(() => ({ type: 'genes/loadList' })), +})); + +jest.mock('redux/selectors', () => ({ + getCellSets: () => () => ({ accessible: true, hierarchy: [], properties: {} }), +})); + +jest.mock('components/data-exploration/spatial/ZipFileStore', () => ({ + __esModule: true, + default: { fromUrl: jest.fn(() => ({ get: jest.fn() })) }, +})); + +jest.mock('zarrita', () => ({ root: jest.fn((s) => s) })); + +// deck.gl core/layers are WebGL — stub the constructors we use so the component +// builds plain capturable objects instead of real GPU layers. These need `new` +// + `this`, so they're genuine function expressions (not arrow callbacks). +/* eslint-disable prefer-arrow-callback, func-names, object-shorthand */ +jest.mock('@deck.gl/core', () => ({ + OrthographicView: jest.fn(function () {}), + OrthographicViewport: jest.fn(function () { + this.unproject = ([x, y]) => [x, y]; + this.project = ([x, y]) => [x, y]; + }), + COORDINATE_SYSTEM: { CARTESIAN: 'cartesian' }, +})); + +jest.mock('@deck.gl/layers', () => ({ + ScatterplotLayer: jest.fn(function (opts) { + return { ...opts, scatterLayer: true }; + }), +})); +/* eslint-enable prefer-arrow-callback, func-names, object-shorthand */ + +jest.mock('components/data-exploration/spatial/bitmaskLayers', () => ({ + BITMASK_LUT_SIZE: 16, + makeBitmaskLayer: jest.fn((opts) => ({ ...opts, __bitmask: true })), +})); + +const mockStore = configureMockStore([thunk]); +const experimentId = 'exp-1'; +const sampleId = 'xenium-sample'; + +const baseConfig = { + dimensions: { width: 500, height: 500 }, + legend: { enabled: true }, + axes: {}, + selectedGenes: [], + geneColors: {}, + showSegmentationOutlines: true, + selectedSample: sampleId, +}; + +const buildState = (config) => ({ + experimentSettings: { info: { sampleIds: [sampleId] } }, + backendStatus: { [experimentId]: { status: { obj2s: { status: 'NOT_CREATED' } } } }, + samples: { [sampleId]: { id: sampleId, type: 'xenium' } }, + componentConfig: { spatialMoleculesMain: { config } }, + // gene dispersions drive the default selection — Sst has the higher dispersion. + genes: { properties: { data: { Gad1: { dispersions: 1 }, Sst: { dispersions: 9 } } } }, +}); + +const renderPlot = async (configOverrides = {}, extraProps = {}) => { + const config = { ...baseConfig, ...configOverrides }; + const { onDefaultGenes = jest.fn(), onDefaultColors = jest.fn() } = extraProps; + await act(async () => { + render( + + + , + ); + }); +}; + +const META = { + version: 2, + genes: [ + { + code: 0, gene: 'Gad1', entry: '0.feather', nPoints: 1, + }, + { + code: 1, gene: 'Sst', entry: '1.feather', nPoints: 1, + }, + ], + rootExtent: { x: [0, 100], y: [0, 200] }, +}; + +const layerById = (id) => (deckProps.layers ?? []).find((l) => l?.id === id); + +describe('SpatialMoleculePlot', () => { + beforeEach(() => { + jest.clearAllMocks(); + deckProps = {}; + getSampleFileUrls.mockResolvedValue([{ url: 'signed://molecules.zip', fileId: sampleId }]); + loadMoleculeMeta.mockResolvedValue(META); + loadOmeZarrGrid.mockResolvedValue({ data: [{ shape: [1, 200, 100] }] }); + loadMoleculeNodes.mockResolvedValue({ + x: Float32Array.from([1]), + y: Float32Array.from([2]), + featureCode: Int32Array.from([0]), + count: 1, + }); + }); + + it('range-reads only the selected genes (no spatial/budget query args)', async () => { + await renderPlot({ selectedGenes: ['Gad1'] }); + + await waitFor(() => expect(loadMoleculeNodes).toHaveBeenCalled()); + const callArgs = loadMoleculeNodes.mock.calls[0][1]; + // only the requested gene's feature_code — the gene-partitioned reader needs + // no bbox/depth/budget (those are gone with the quadtree) + expect(callArgs.genes).toEqual([0]); + expect(callArgs.bbox).toBeUndefined(); + expect(callArgs.depth).toBeUndefined(); + expect(callArgs.maxPoints).toBeUndefined(); + expect(callArgs.maxRenderedPoints).toBeUndefined(); + }); + + it('does not re-read molecules on zoom/pan — only persists the camera', async () => { + const onZoomChange = jest.fn(); + await act(async () => { + render( + + + , + ); + }); + + await waitFor(() => expect(loadMoleculeNodes).toHaveBeenCalledTimes(1)); + + // simulate a zoom gesture through the captured deck.gl handler + await act(async () => { + deckProps.onViewStateChange({ viewState: { target: [50, 100, 0], zoom: 3 } }); + }); + + // camera persisted, but molecules are NOT re-read (still a single load) + await waitFor(() => expect(onZoomChange).toHaveBeenCalled()); + expect(loadMoleculeNodes).toHaveBeenCalledTimes(1); + }); + + it('restores the persisted zoom region on mount (navigation/reload)', async () => { + // a saved sub-region of the [0,100]x[0,200] full extent + await renderPlot({ + selectedGenes: ['Gad1'], + axesRanges: { + xAxisAuto: false, xMin: 10, yMin: 20, xMax: 60, yMax: 120, + }, + }); + + await waitFor(() => expect(screen.getByTestId('deckgl')).toBeInTheDocument()); + // deck.gl mounts centred on the saved region (35, 70) — NOT the full-extent + // centre (50, 100) it would fit to with no persisted zoom. + await waitFor(() => expect(deckProps.initialViewState?.target).toEqual([35, 70, 0])); + }); + + it('fits the full extent when no zoom is persisted (xAxisAuto)', async () => { + await renderPlot({ selectedGenes: ['Gad1'] }); + + await waitFor(() => expect(screen.getByTestId('deckgl')).toBeInTheDocument()); + // full-extent centre of [0,100]x[0,200] + await waitFor(() => expect(deckProps.initialViewState?.target).toEqual([50, 100, 0])); + }); + + it('renders a molecule ScatterplotLayer plus the grey segmentation outline layer', async () => { + await renderPlot({ selectedGenes: ['Gad1'] }); + + await waitFor(() => expect(screen.getByTestId('deckgl')).toBeInTheDocument()); + await waitFor(() => expect(layerById('molecules')).toBeDefined()); + + const molecules = layerById('molecules'); + expect(molecules.scatterLayer).toBe(true); + expect(molecules.data.length).toBe(1); + + await waitFor(() => expect(layerById('molecule-seg-outline')).toBeDefined()); + expect(layerById('molecule-seg-outline').showOutlineOnly).toBe(true); + // there is no fill layer — only outlines are supported + expect(layerById('molecule-seg-fill')).toBeUndefined(); + }); + + it('omits the segmentation outline layer (and the OME-Zarr load) when outlines are off', async () => { + await renderPlot({ selectedGenes: ['Gad1'], showSegmentationOutlines: false }); + + await waitFor(() => expect(layerById('molecules')).toBeDefined()); + expect(layerById('molecule-seg-outline')).toBeUndefined(); + // the segmentation OME-Zarr is never loaded when outlines are off + expect(loadOmeZarrGrid).not.toHaveBeenCalled(); + }); + + it('seeds the top-DISPERSION panel genes (not alphabetical) when none are selected', async () => { + const onDefaultGenes = jest.fn(); + await renderPlot({ selectedGenes: [] }, { onDefaultGenes }); + + // Sst (dispersion 9) ranks above Gad1 (dispersion 1) → dispersion order, NOT + // alphabetical (which would be ['Gad1','Sst']). + await waitFor(() => expect(onDefaultGenes).toHaveBeenCalledWith(['Sst', 'Gad1'])); + // … and nothing is loaded while the selection is empty. + expect(loadMoleculeNodes).not.toHaveBeenCalled(); + expect(screen.getByText(/Select one or more genes/i)).toBeInTheDocument(); + }); + + it('defaults per-gene colours from the palette (first available)', async () => { + const onDefaultColors = jest.fn(); + await renderPlot({ selectedGenes: ['Gad1', 'Sst'], geneColors: {} }, { onDefaultColors }); + + // first available palette colours, in selection order + await waitFor(() => expect(onDefaultColors).toHaveBeenCalledWith({ + Gad1: MOLECULE_PALETTE[0], + Sst: MOLECULE_PALETTE[1], + })); + }); + + it('re-seeds a fresh colour for a gene whose colour was nulled (removed then re-added)', async () => { + const onDefaultColors = jest.fn(); + // Gad1 carries a null colour (the removed-gene sentinel) — it must be reseeded + // from the palette, not left null or given a stale value. + await renderPlot({ selectedGenes: ['Gad1'], geneColors: { Gad1: null } }, { onDefaultColors }); + + await waitFor(() => expect(onDefaultColors).toHaveBeenCalledWith({ + Gad1: MOLECULE_PALETTE[0], + })); + }); + + it('wires the marker size config into the ScatterplotLayer radius', async () => { + await renderPlot({ selectedGenes: ['Gad1'], marker: { size: 3, opacity: 8 } }); + + await waitFor(() => expect(layerById('molecules')).toBeDefined()); + // marker.size maps 1:1 to the point radius in px + expect(layerById('molecules').getRadius).toBe(3); + }); + + it('renders the title + axis tick labels in the chrome overlay', async () => { + await renderPlot({ selectedGenes: ['Gad1'], title: { text: 'My Molecules', fontSize: 16 } }); + + await waitFor(() => expect(screen.getByTestId('deckgl')).toBeInTheDocument()); + // title drawn as SVG text + await waitFor(() => expect(screen.getByText('My Molecules')).toBeInTheDocument()); + // an axis tick label (origin) is present + expect(screen.getAllByText('0').length).toBeGreaterThan(0); + }); + + it('offers a PNG/SVG export control', async () => { + await renderPlot({ selectedGenes: ['Gad1'] }); + + await waitFor(() => expect(screen.getByTestId('deckgl')).toBeInTheDocument()); + expect(screen.getByTitle('Export plot')).toBeInTheDocument(); + }); +}); diff --git a/src/__test__/components/plots/SpatialOutlierFilterPlot.test.jsx b/src/__test__/components/plots/SpatialOutlierFilterPlot.test.jsx new file mode 100644 index 0000000000..dd77d0cb00 --- /dev/null +++ b/src/__test__/components/plots/SpatialOutlierFilterPlot.test.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { act } from 'react-dom/test-utils'; +import { render } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import SpatialOutlierFilterPlot from 'components/plots/SpatialOutlierFilterPlot'; +import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; + +jest.mock('utils/data-management/downloadSampleFile', () => ({ + getSampleFileUrls: jest.fn(), +})); + +// Heavy rendering deps are irrelevant to the URL-fetch logic under test. +jest.mock('react-vega', () => ({ Vega: () => null })); +jest.mock('vega-webgl-renderer', () => ({}), { virtual: true }); + +jest.mock('components/plots/useSpatialStream', () => ({ + __esModule: true, + default: jest.fn(() => ({ + imageDims: null, + segmentationsAvailable: false, + segProbeDone: true, + tissueImageData: null, + segOverlayData: null, + onViewportChange: jest.fn(), + ready: false, + })), +})); + +jest.mock('components/plots/usePreventWheelScroll', () => ({ + __esModule: true, + default: () => ({ current: null }), +})); + +const mockStore = configureMockStore([thunk]); + +const experimentId = 'exp-1'; +const sampleId = 'xenium-sample'; + +const config = { + dimensions: { width: 100, height: 100 }, + colour: { gradient: 'default', toggleInvert: '#FFFFFF', masterColour: '#000000' }, + marker: { opacity: 10, outline: false }, + legend: { enabled: true }, +}; + +const renderPlot = async (technology) => { + const store = mockStore({ + samples: { [sampleId]: { id: sampleId, type: technology } }, + }); + + await act(async () => { + render( + + + , + ); + }); +}; + +describe('SpatialOutlierFilterPlot — imageless technology (e.g. Xenium)', () => { + beforeEach(() => { + jest.clearAllMocks(); + getSampleFileUrls.mockResolvedValue([{ url: 'http://example.com/seg.zarr.zip' }]); + }); + + it('skips the tissue-image (ome_zarr_zip) fetch for an imageless tech', async () => { + await renderPlot('xenium'); + + const requested = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requested).not.toContain('ome_zarr_zip'); + }); + + it('still probes the segmentation OME-Zarr', async () => { + await renderPlot('xenium'); + + const requested = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requested).toContain('segmentations_ome_zarr_zip'); + }); + + it('does fetch the tissue image for a non-imageless spatial tech (visium_hd)', async () => { + await renderPlot('visium_hd'); + + const requested = getSampleFileUrls.mock.calls.map(([, , fileType]) => fileType); + expect(requested).toContain('ome_zarr_zip'); + }); +}); diff --git a/src/__test__/components/plots/loadSegmentationOverlay.test.js b/src/__test__/components/plots/loadSegmentationOverlay.test.js new file mode 100644 index 0000000000..55f0e4829f --- /dev/null +++ b/src/__test__/components/plots/loadSegmentationOverlay.test.js @@ -0,0 +1,82 @@ +import { + parseHexColor, paintSegOverlayPixels, colorSegmentationOverlay, +} from 'components/plots/loadSegmentationOverlay'; + +// Paint a decoded label tile into a fresh RGBA buffer (the pure pixel logic) and +// return it for inspection — no canvas needed. +const paint = (decoded, colorMap, options = {}) => { + const w = decoded.innerW ?? decoded.regionW; + const h = decoded.innerH ?? decoded.regionH; + const px = new Uint8ClampedArray(w * h * 4); + paintSegOverlayPixels(px, decoded, colorMap, options); + return px; +}; + +// a 3x1 strip: cell 1 (label 1), cell 2 (label 2), background (0) +const decoded3x1 = { + flatData: [1, 2, 0], + regionW: 3, + regionH: 1, + innerX: 0, + innerY: 0, + innerW: 3, + innerH: 1, + extent: { + xMin: 0, xMax: 3, yMin: 0, yMax: 1, + }, +}; + +describe('parseHexColor', () => { + it('parses a 6-digit hex (with or without #) to [r, g, b]', () => { + expect(parseHexColor('#ff8800')).toEqual([255, 136, 0]); + expect(parseHexColor('ff8800')).toEqual([255, 136, 0]); + }); + + it('falls back to grey for short/empty/invalid input', () => { + expect(parseHexColor('#fff')).toEqual([128, 128, 128]); + expect(parseHexColor('')).toEqual([128, 128, 128]); + expect(parseHexColor(undefined)).toEqual([128, 128, 128]); + }); +}); + +describe('colorSegmentationOverlay', () => { + it('paints cells in the colour map and hides everything else', () => { + // only cell id 0 (label 1) is in the active scheme + const colorMap = new Map([[0, [255, 0, 0]]]); + const px = paint(decoded3x1, colorMap, { opacity: 1, outline: false }); + + // label 1 → red, fully opaque + expect([px[0], px[1], px[2], px[3]]).toEqual([255, 0, 0, 255]); + // label 2 not in the map (e.g. filtered out earlier) → transparent, NOT grey + expect(px[7]).toBe(0); + // background → transparent + expect(px[11]).toBe(0); + }); + + it('applies the opacity slider to the cell fill alpha', () => { + const colorMap = new Map([[0, [10, 20, 30]]]); + const px = paint(decoded3x1, colorMap, { opacity: 0.5, outline: false }); + expect([px[0], px[1], px[2]]).toEqual([10, 20, 30]); + expect(px[3]).toBe(Math.round(0.5 * 255)); // 128 + }); + + it('outlines only cells in the colour map (filtered cells get no stray outline)', () => { + const colorMap = new Map([[0, [255, 0, 0]]]); + const px = paint(decoded3x1, colorMap, { opacity: 0.5, outline: true }); + + // label 1 is an edge (neighbours differ) AND is coloured → outlined (alpha 255) + expect(px[3]).toBe(255); + // label 2 is an edge too but not in the map → no outline, stays hidden + expect(px[7]).toBe(0); + }); + + it('respects a per-cell alpha override (4th colour element)', () => { + const colorMap = new Map([[0, [1, 2, 3, 200]]]); + const px = paint(decoded3x1, colorMap, { opacity: 1, outline: false }); + expect(px[3]).toBe(200); + }); + + it('returns null when there is no decoded tile', () => { + expect(colorSegmentationOverlay(null, new Map())).toBeNull(); + }); +}); diff --git a/src/__test__/components/plots/spatialTileCache.test.js b/src/__test__/components/plots/spatialTileCache.test.js new file mode 100644 index 0000000000..3af796fbff --- /dev/null +++ b/src/__test__/components/plots/spatialTileCache.test.js @@ -0,0 +1,108 @@ +// Unit tests for the tile cache + global request scheduler. The actual decoders +// (readImageTile / readSegTile) are mocked so we can drive resolution timing and +// assert concurrency capping, superseded-request skipping, dedup and peek. + +jest.mock('components/plots/getImageUrls', () => ({ readImageTile: jest.fn() })); +jest.mock('components/plots/loadSegmentationOverlay', () => ({ + readSegTile: jest.fn(), + releaseOverlay: jest.fn(), +})); + +const tick = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +const makeTile = (level, tx, ty) => ({ + level, + tx, + ty, + x0: 0, + x1: 1, + y0: 0, + y1: 1, + extent: { + xMin: 0, xMax: 1, yMin: 0, yMax: 1, + }, +}); + +const deferred = () => { + let resolve; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +}; + +describe('spatialTileCache', () => { + let loadTissueTile; + let peekTissueTile; + let readImageTile; + let deferreds; + + beforeEach(() => { + jest.resetModules(); + deferreds = []; + // eslint-disable-next-line global-require + readImageTile = require('components/plots/getImageUrls').readImageTile; + readImageTile.mockReset(); + readImageTile.mockImplementation(() => { + const d = deferred(); + deferreds.push(d); + return d.promise; + }); + // eslint-disable-next-line global-require + const cache = require('components/plots/spatialTileCache'); + loadTissueTile = cache.loadTissueTile; + peekTissueTile = cache.peekTissueTile; + }); + + const wanted = () => true; + + it('caps the number of tiles decoding concurrently (scheduler)', async () => { + const pyramid = {}; + for (let i = 0; i < 10; i += 1) { + loadTissueTile(pyramid, 'exp-s', makeTile(0, i, 0), { isWanted: wanted, priority: 1 }); + } + await tick(); + // MAX_CONCURRENT_TILE_LOADS = 6 → only 6 decodes start, the rest queue + expect(readImageTile).toHaveBeenCalledTimes(6); + + // completing one frees a slot → the 7th starts + deferreds[0].resolve({ url: 'seg-overlay://1', extent: makeTile(0, 0, 0).extent }); + await tick(); + expect(readImageTile).toHaveBeenCalledTimes(7); + }); + + it('skips a request that is no longer wanted before it starts (no decode)', async () => { + const promise = loadTissueTile({}, 'exp-s', makeTile(0, 0, 0), { isWanted: () => false }); + await expect(promise).resolves.toBeNull(); + expect(readImageTile).not.toHaveBeenCalled(); + }); + + it('dedups concurrent requests for the same tile', async () => { + const tile = makeTile(0, 1, 1); + loadTissueTile({}, 'exp-s', tile, { isWanted: wanted }); + loadTissueTile({}, 'exp-s', tile, { isWanted: wanted }); + await tick(); + expect(readImageTile).toHaveBeenCalledTimes(1); + }); + + it('resolves + caches the tile for synchronous peek once decoded', async () => { + const tile = makeTile(0, 2, 3); + const result = { url: 'seg-overlay://9', extent: tile.extent }; + const promise = loadTissueTile({}, 'exp-s', tile, { isWanted: wanted }); + await tick(); + deferreds[0].resolve(result); + await expect(promise).resolves.toBe(result); + expect(peekTissueTile('exp-s', tile)).toBe(result); + }); + + it('does not cache a skipped request, so it can be re-requested later', async () => { + const tile = makeTile(0, 5, 5); + let want = false; + await loadTissueTile({}, 'exp-s', tile, { isWanted: () => want }); + expect(peekTissueTile('exp-s', tile)).toBeNull(); + + // now it's wanted → a fresh request actually decodes + want = true; + loadTissueTile({}, 'exp-s', tile, { isWanted: () => want }); + await tick(); + expect(readImageTile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__test__/components/plots/styling/ColourReverse.test.jsx b/src/__test__/components/plots/styling/ColourReverse.test.jsx new file mode 100644 index 0000000000..762ba0e37f --- /dev/null +++ b/src/__test__/components/plots/styling/ColourReverse.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ColourReverse from 'components/plots/styling/ColourReverse'; + +const mockOnUpdate = jest.fn(); + +const renderColourReverse = (config) => render( + , +); + +describe('ColourReverse', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the Standard and Reversed options', () => { + renderColourReverse({ colour: { reverseCbar: false } }); + + expect(screen.getByText('Standard')).toBeInTheDocument(); + expect(screen.getByText('Reversed')).toBeInTheDocument(); + }); + + it('reflects reverseCbar=false as Standard selected', () => { + renderColourReverse({ colour: { reverseCbar: false } }); + + const standard = screen.getByRole('radio', { name: 'Standard' }); + expect(standard).toBeChecked(); + }); + + it('reflects reverseCbar=true as Reversed selected', () => { + renderColourReverse({ colour: { reverseCbar: true } }); + + const reversed = screen.getByRole('radio', { name: 'Reversed' }); + expect(reversed).toBeChecked(); + }); + + it('coerces a truthy non-boolean reverseCbar to the Reversed option', () => { + renderColourReverse({ colour: { reverseCbar: 1 } }); + + expect(screen.getByRole('radio', { name: 'Reversed' })).toBeChecked(); + }); + + it('calls onUpdate with reverseCbar=true when Reversed is picked', () => { + renderColourReverse({ colour: { reverseCbar: false } }); + + userEvent.click(screen.getByRole('radio', { name: 'Reversed' })); + + expect(mockOnUpdate).toHaveBeenCalledWith({ colour: { reverseCbar: true } }); + }); + + it('calls onUpdate with reverseCbar=false when Standard is picked', () => { + renderColourReverse({ colour: { reverseCbar: true } }); + + userEvent.click(screen.getByRole('radio', { name: 'Standard' })); + + expect(mockOnUpdate).toHaveBeenCalledWith({ colour: { reverseCbar: false } }); + }); +}); diff --git a/src/__test__/components/plots/styling/embedding-continuous/SelectData.test.jsx b/src/__test__/components/plots/styling/embedding-continuous/SelectData.test.jsx new file mode 100644 index 0000000000..935d8bd8bf --- /dev/null +++ b/src/__test__/components/plots/styling/embedding-continuous/SelectData.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import userEvent from '@testing-library/user-event'; + +import SelectData from 'components/plots/styling/embedding-continuous/SelectData'; +import { createHierarchyFromTree, createPropertiesFromTree } from 'redux/reducers/cellSets/helpers'; +import { plotTypes } from 'utils/constants'; + +import mockCellSets from '__test__/data/cell_sets.json'; + +const mockOnUpdate = jest.fn(); + +const cellSetsStore = { + accessible: true, + error: false, + hierarchy: createHierarchyFromTree(mockCellSets.cellSets), + properties: createPropertiesFromTree(mockCellSets.cellSets), +}; + +// First metadataCategorical parent is 'sample'; grab its first child key. +const firstSampleKey = createHierarchyFromTree(mockCellSets.cellSets) + .find(({ key }) => key === 'sample').children[0].key; + +const renderSelectData = ({ + config = { selectedSample: null }, + cellSets = cellSetsStore, + plotType = null, +} = {}) => render( + , +); + +describe('embedding-continuous SelectData', () => { + beforeEach(() => jest.clearAllMocks()); + + it('uses the non-spatial label and offers an All option by default', async () => { + await act(async () => { renderSelectData(); }); + + expect(screen.getByText('Included Samples:')).toBeInTheDocument(); + // no Toggle Image control for non-spatial + expect(screen.queryByText('Toggle Image:')).not.toBeInTheDocument(); + }); + + it('uses the spatial label and a Toggle Image control for spatial plot types', async () => { + await act(async () => { + renderSelectData({ plotType: plotTypes.SPATIAL_FEATURE }); + }); + + expect(screen.getByText('Selected sample:')).toBeInTheDocument(); + expect(screen.getByText('Toggle Image:')).toBeInTheDocument(); + }); + + it('auto-populates the selected sample with the first sample for spatial plots', async () => { + await act(async () => { + renderSelectData({ + plotType: plotTypes.SPATIAL_FEATURE, + config: { selectedSample: null }, + }); + }); + + // The combobox shows the first sample's display name (no 'All' for spatial). + const sampleName = cellSetsStore.properties[firstSampleKey].name; + expect(screen.getByText(sampleName)).toBeInTheDocument(); + expect(screen.queryByText('All')).not.toBeInTheDocument(); + }); + + it('toggling the image dispatches showImage via onUpdate', async () => { + await act(async () => { + renderSelectData({ plotType: plotTypes.SPATIAL_FEATURE }); + }); + + userEvent.click(screen.getByRole('radio', { name: 'Hide' })); + + expect(mockOnUpdate).toHaveBeenCalledWith({ showImage: false }); + }); + + it('shows an error message when cellSets failed to load', async () => { + await act(async () => { + renderSelectData({ cellSets: { ...cellSetsStore, error: true } }); + }); + + expect(screen.getByText(/Error loading cell set/i)).toBeInTheDocument(); + }); +}); diff --git a/src/__test__/components/plots/usePreventWheelScroll.test.js b/src/__test__/components/plots/usePreventWheelScroll.test.js new file mode 100644 index 0000000000..5eb825c8ba --- /dev/null +++ b/src/__test__/components/plots/usePreventWheelScroll.test.js @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react'; +import usePreventWheelScroll from 'components/plots/usePreventWheelScroll'; + +const setup = (enabled) => { + const { result } = renderHook(() => usePreventWheelScroll(enabled)); + const ref = result.current; // callback ref + const wrapper = document.createElement('div'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + document.body.appendChild(wrapper); + return { ref, wrapper, canvas }; +}; + +const wheelOn = (el) => { + const evt = new WheelEvent('wheel', { bubbles: true, cancelable: true }); + el.dispatchEvent(evt); + return evt; +}; + +describe('usePreventWheelScroll', () => { + it('preventDefaults a wheel over the plot canvas (so zoom does not scroll the page)', () => { + const { ref, wrapper, canvas } = setup(true); + ref(wrapper); + expect(wheelOn(canvas).defaultPrevented).toBe(true); + }); + + it('lets a wheel over the (non-canvas) wrapper through so the page can scroll', () => { + const { ref, wrapper } = setup(true); + ref(wrapper); + expect(wheelOn(wrapper).defaultPrevented).toBe(false); + }); + + it('does nothing when disabled (e.g. mini previews)', () => { + const { ref, wrapper, canvas } = setup(false); + ref(wrapper); + expect(wheelOn(canvas).defaultPrevented).toBe(false); + }); + + it('detaches the listener when the ref is unset', () => { + const { ref, wrapper, canvas } = setup(true); + ref(wrapper); + ref(null); // element unmounts + expect(wheelOn(canvas).defaultPrevented).toBe(false); + }); +}); diff --git a/src/__test__/components/plots/zarrPyramid.test.js b/src/__test__/components/plots/zarrPyramid.test.js new file mode 100644 index 0000000000..e9c5ed3853 --- /dev/null +++ b/src/__test__/components/plots/zarrPyramid.test.js @@ -0,0 +1,382 @@ +// Mock the heavy zarr/zip deps so openOmePyramid's caching can be exercised +// without touching real I/O. resolvePyramidRegion is pure and needs no mocks. +const mockOpen = jest.fn(); +const mockFromUrl = jest.fn(() => ({ store: true })); + +jest.mock('zarrita', () => ({ + root: jest.fn(() => ({ resolve: (path) => ({ path }) })), + open: (...args) => mockOpen(...args), +})); + +jest.mock('components/data-exploration/spatial/ZipFileStore', () => ({ + __esModule: true, + default: { fromUrl: (...args) => mockFromUrl(...args) }, +})); + +// eslint-disable-next-line import/first +import { + resolvePyramidRegion, openOmePyramid, pickLevel, tilesForViewport, +} from 'components/plots/zarrPyramid'; + +// Helper: build a fake `levels` array of { shape } where shape is [h, w] (or +// [c, h, w]); resolvePyramidRegion only reads the last two dims. +const makeLevels = (...dims) => dims.map((shape) => ({ shape })); + +describe('pickLevel', () => { + const fullW = 1000; + const fullH = 800; + // full, half, quarter + const levels = makeLevels([800, 1000], [400, 500], [200, 250]); + + it('picks the coarsest level for the full slide at a small output', () => { + expect(pickLevel(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 200, outputHeight: 200, + })).toBe(2); + }); + + it('picks a finer level as the viewport shrinks (zoom in)', () => { + // a small viewport still needs >= output px → finest level + const lvl = pickLevel(levels, fullW, fullH, { + xMin: 0, xMax: 100, yMin: 0, yMax: 80, outputWidth: 256, outputHeight: 256, + }); + expect(lvl).toBe(0); + }); + + it('falls back to full resolution when no level satisfies the output size', () => { + expect(pickLevel(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 5000, outputHeight: 5000, + })).toBe(0); + }); +}); + +describe('tilesForViewport', () => { + const fullW = 1000; + const fullH = 800; + const levels = makeLevels([800, 1000], [400, 500], [200, 250]); + + it('tiles the full extent at the coarsest level and covers it with 1px overlap', () => { + const tiles = tilesForViewport(levels, fullW, fullH, 2, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, + }, 512); + // level 2 is 250x200 → a single 512 tile covers it + expect(tiles).toHaveLength(1); + const [t] = tiles; + expect(t.extent.xMin).toBeCloseTo(0); + expect(t.extent.xMax).toBeCloseTo(1000); + expect(t.extent.yMin).toBeCloseTo(0); + expect(t.extent.yMax).toBeCloseTo(800); + }); + + it('returns multiple tiles that meet exactly (no gap/overlap) at a finer level', () => { + // level 0 is 1000x800; with tileSize 256 → ceil(1000/256)=4 cols, ceil(800/256)=4 rows + const tiles = tilesForViewport(levels, fullW, fullH, 0, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, + }, 256); + expect(tiles.length).toBe(16); + + // adjacent tiles share their edge exactly in data space; the on-screen seam is + // removed by rounding the mark edges to integer pixels in the Vega spec + const byCoord = (tx, ty) => tiles.find((t) => t.tx === tx && t.ty === ty); + const left = byCoord(0, 0); + const right = byCoord(1, 0); + expect(left.extent.xMax).toBeCloseTo(right.extent.xMin); + }); + + it('returns no tiles for an empty viewport', () => { + expect(tilesForViewport(levels, fullW, fullH, 0, { + xMin: 500, xMax: 500, yMin: 0, yMax: 800, + }, 256)).toHaveLength(0); + }); + + it('only returns tiles intersecting a sub-region viewport', () => { + const all = tilesForViewport(levels, fullW, fullH, 0, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, + }, 256); + const sub = tilesForViewport(levels, fullW, fullH, 0, { + xMin: 0, xMax: 200, yMin: 600, yMax: 800, + }, 256); + expect(sub.length).toBeLessThan(all.length); + expect(sub.length).toBeGreaterThan(0); + }); +}); + +describe('resolvePyramidRegion', () => { + // A 3-level pyramid over a 1000x800 (w x h) slide: full, half, quarter. + const fullW = 1000; + const fullH = 800; + const levels = makeLevels([800, 1000], [400, 500], [200, 250]); + + it('returns null for an empty viewport (degenerate x or y window)', () => { + expect(resolvePyramidRegion(levels, fullW, fullH, { + xMin: 100, xMax: 100, yMin: 0, yMax: 800, outputWidth: 256, outputHeight: 256, + })).toBeNull(); + + expect(resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 500, yMax: 500, outputWidth: 256, outputHeight: 256, + })).toBeNull(); + }); + + it('clamps the viewport to the slide bounds', () => { + const region = resolvePyramidRegion(levels, fullW, fullH, { + xMin: -50, xMax: 5000, yMin: -10, yMax: 9999, outputWidth: 1, outputHeight: 1, + }); + // extent is clamped to [0, fullW] / [0, fullH] + expect(region.extent).toEqual({ + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, + }); + }); + + it('picks the coarsest level that still satisfies the requested output size', () => { + // Whole slide, small output (250x200 px) → coarsest level (index 2) suffices: + // fracX*lw = 1*250 >= 250 and fracY*lh = 1*200 >= 200. + const coarse = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 250, outputHeight: 200, + }); + expect(coarse.level).toBe(2); + + // Whole slide, larger output → only full-res level (0) qualifies. + const fine = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 600, outputHeight: 600, + }); + expect(fine.level).toBe(0); + }); + + it('selects a finer level as the viewport fraction shrinks (zoom in)', () => { + // A 10% wide/tall crop with a 256px output needs ~2560 px across the full + // slide; only the full-res level (1000px) gets close, so it picks level 0. + const zoomed = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 100, yMin: 0, yMax: 80, outputWidth: 50, outputHeight: 50, + }); + expect(zoomed.level).toBe(0); + }); + + it('flips the y window (zarr row 0 = top = high data-y)', () => { + // Request the BOTTOM strip in data space (low y). After the y-flip the zarr + // rows should come from the BOTTOM of the array (high row indices). + const bottom = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 100, outputWidth: 1, outputHeight: 1, + }); + // chosen level 2 (250x200). scaleY = 200/800 = 0.25. + // y0 = floor((800 - 100) * 0.25) = floor(175) = 175 + // y1 = ceil((800 - 0) * 0.25) = 200 + expect(bottom.level).toBe(2); + expect(bottom.y0).toBe(175); + expect(bottom.y1).toBe(200); + + // The complementary TOP strip (high data-y) maps to low zarr rows. + const top = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 700, yMax: 800, outputWidth: 1, outputHeight: 1, + }); + expect(top.y0).toBe(0); + expect(top.y1).toBe(25); + }); + + it('scales the x window into the chosen level grid', () => { + const region = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 500, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 1, outputHeight: 1, + }); + // level 2: scaleX = 250/1000 = 0.25. x0 = floor(500*0.25)=125, x1=ceil(1000*0.25)=250. + expect(region.x0).toBe(125); + expect(region.x1).toBe(250); + expect(region.regionW).toBe(125); + }); + + it('reports the chosen shape, ndim, and region dimensions', () => { + const region = resolvePyramidRegion(levels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 250, outputHeight: 200, + }); + expect(region.shape).toEqual([200, 250]); + expect(region.ndim).toBe(2); + expect(region.regionW).toBe(region.x1 - region.x0); + expect(region.regionH).toBe(region.y1 - region.y0); + }); + + it('reads the trailing two dims when levels carry a channel axis', () => { + const chLevels = makeLevels([3, 800, 1000], [3, 200, 250]); + const region = resolvePyramidRegion(chLevels, fullW, fullH, { + xMin: 0, xMax: 1000, yMin: 0, yMax: 800, outputWidth: 250, outputHeight: 200, + }); + expect(region.level).toBe(1); + expect(region.ndim).toBe(3); + expect(region.shape).toEqual([3, 200, 250]); + }); +}); + +describe('per-level coordinate frame (cross-level registration)', () => { + // When a level's dimensions are a ROUNDED (not exact) divisor of the full size, + // the shape ratio (lw/fullW) disagrees with the true downsample factor, so coarse + // and fine tiles land at slightly different data coords. The OME-Zarr scale + // transforms give the exact factor, keeping every level on one coordinate frame. + const fullW = 999; + const fullH = 801; + // level 0: 999x801 (scale 1); level 1: ceil-rounded 500x401 but TRUE factor is 2. + const withTransforms = [ + { + shape: [801, 999], pxPerFullX: 1, pxPerFullY: 1, offsetFullX: 0, offsetFullY: 0, + }, + { + shape: [401, 500], pxPerFullX: 0.5, pxPerFullY: 0.5, offsetFullX: 0, offsetFullY: 0, + }, + ]; + + it('uses the exact downsample factor, not the rounded shape ratio', () => { + // A level-1 tile spanning [0, 500) level-px should cover [0, 1000) full-px (factor + // 2), NOT [0, 500/500*999] = [0, 999] that the shape ratio would give. + const tiles = tilesForViewport(withTransforms, fullW, fullH, 1, { + xMin: 0, xMax: fullW, yMin: 0, yMax: fullH, + }, 512); + expect(tiles).toHaveLength(1); + // x1 = 500 level-px / 0.5 = 1000 full-px (exact factor), clamped only by the viewport + expect(tiles[0].extent.xMin).toBeCloseTo(0); + expect(tiles[0].extent.xMax).toBeCloseTo(1000); + }); + + it('keeps a level-1 window aligned to the exact factor in resolvePyramidRegion', () => { + const region = resolvePyramidRegion(withTransforms, fullW, fullH, { + xMin: 400, xMax: 600, yMin: 0, yMax: fullH, outputWidth: 1, outputHeight: 1, + }); + expect(region.level).toBe(1); + // x0 = floor((400 - 0) * 0.5) = 200, x1 = ceil(600 * 0.5) = 300 — exact halves, + // independent of the rounded 500-px width. + expect(region.x0).toBe(200); + expect(region.x1).toBe(300); + }); + + it('honours a per-level translation offset', () => { + // A level shifted by +4 full-px: level-px p maps to full-px p/0.5 + 4. + const shifted = [ + { + shape: [801, 999], pxPerFullX: 1, pxPerFullY: 1, offsetFullX: 0, offsetFullY: 0, + }, + { + shape: [401, 500], pxPerFullX: 0.5, pxPerFullY: 0.5, offsetFullX: 4, offsetFullY: 0, + }, + ]; + const tiles = tilesForViewport(shifted, fullW, fullH, 1, { + xMin: 0, xMax: fullW, yMin: 0, yMax: fullH, + }, 512); + // x0 = floor((0 - 4) * 0.5) clamped to 0 → 0; extent.xMin = 0/0.5 + 4 = 4 + expect(tiles[0].extent.xMin).toBeCloseTo(4); + }); +}); + +describe('openOmePyramid', () => { + beforeEach(() => { + jest.clearAllMocks(); + // open() is called once for the root group then once per dataset path. + mockOpen.mockImplementation((node, { kind }) => { + if (kind === 'group') { + return Promise.resolve({ + attrs: { + multiscales: [{ datasets: [{ path: '0' }, { path: '1' }], axes: [{ name: 'y' }, { name: 'x' }] }], + }, + }); + } + // array open + return Promise.resolve({ shape: [600, 900] }); + }); + }); + + it('opens the pyramid and exposes level shapes + full dimensions', async () => { + const result = await openOmePyramid('https://example.com/unique-1.zarr.zip'); + + expect(result.fullW).toBe(900); + expect(result.fullH).toBe(600); + expect(result.levels).toHaveLength(2); + expect(result.axesMetadata).toEqual([{ name: 'y' }, { name: 'x' }]); + expect(mockFromUrl).toHaveBeenCalledWith('https://example.com/unique-1.zarr.zip'); + }); + + it('infers exact integer factors from shapes for v0.3 pyramids (no transforms)', async () => { + // v0.3: datasets carry NO coordinateTransformations. Our writer uses + // scale_factors [2,4,8,16]; with a non-divisible full width the level dims are + // floor-rounded, so the raw fullW/levelW ratio drifts off the true 2^L. The + // inference must recover EXACTLY 1, 1/2, 1/4, 1/8, 1/16. + const dims = { + 0: [801, 999], 1: [401, 500], 2: [200, 250], 3: [100, 125], 4: [50, 62], + }; + mockOpen.mockImplementation((node, { kind }) => { + if (kind === 'group') { + return Promise.resolve({ + attrs: { + multiscales: [{ + axes: [{ name: 'y' }, { name: 'x' }], + datasets: [0, 1, 2, 3, 4].map((p) => ({ path: String(p) })), + }], + }, + }); + } + return Promise.resolve({ shape: dims[node.path] }); + }); + + const result = await openOmePyramid('https://example.com/v03.zarr.zip'); + const pxX = result.levels.map((l) => l.pxPerFullX); + const pxY = result.levels.map((l) => l.pxPerFullY); + expect(pxX).toEqual([1, 1 / 2, 1 / 4, 1 / 8, 1 / 16]); + expect(pxY).toEqual([1, 1 / 2, 1 / 4, 1 / 8, 1 / 16]); + // offsets are 0 — skimage centre-aligned resize needs no half-pixel term + expect(result.levels.every((l) => l.offsetFullX === 0 && l.offsetFullY === 0)).toBe(true); + + // ...and the raw shape ratio would have been WRONG at the coarsest level: + // 999/62 = 16.11 ≠ 16, the few-pixel drift that displaced the coarse overlay. + expect(999 / 62).not.toBeCloseTo(16, 1); + }); + + it('caches by URL: a second call for the same URL does not re-open the store', async () => { + const url = 'https://example.com/unique-2.zarr.zip'; + const first = openOmePyramid(url); + const second = openOmePyramid(url); + + // Same in-flight promise returned (cached) — store opened only once. + expect(second).toBe(first); + await first; + expect(mockFromUrl).toHaveBeenCalledTimes(1); + }); + + it('uses distinct cache entries for distinct URLs', async () => { + await openOmePyramid('https://example.com/a.zarr.zip'); + await openOmePyramid('https://example.com/b.zarr.zip'); + expect(mockFromUrl).toHaveBeenCalledTimes(2); + }); + + it('falls back to the default dataset path when multiscale metadata is missing', async () => { + mockOpen.mockImplementation((node, { kind }) => { + if (kind === 'group') return Promise.reject(new Error('no metadata')); + return Promise.resolve({ shape: [120, 340] }); + }); + + const result = await openOmePyramid('https://example.com/no-meta.zarr.zip'); + expect(result.levels).toHaveLength(1); + expect(result.fullW).toBe(340); + expect(result.fullH).toBe(120); + expect(result.axesMetadata).toBeNull(); + }); + + it('evicts the cache entry when opening rejects so a retry can re-open', async () => { + const url = 'https://example.com/fails-then-succeeds.zarr.zip'; + + // First attempt: group open is fine (gives the dataset list) but the array + // open hard-fails, so the whole open rejects. + mockOpen.mockImplementation((node, { kind }) => { + if (kind === 'group') { + return Promise.resolve({ attrs: { multiscales: [{ datasets: [{ path: '0' }] }] } }); + } + return Promise.reject(new Error('array fail')); + }); + + await expect(openOmePyramid(url)).rejects.toThrow('array fail'); + + // Cache entry was deleted on rejection, so a retry re-opens the store. + mockOpen.mockImplementation((node, { kind }) => { + if (kind === 'group') { + return Promise.resolve({ attrs: { multiscales: [{ datasets: [{ path: '0' }] }] } }); + } + return Promise.resolve({ shape: [10, 20] }); + }); + + const retry = await openOmePyramid(url); + expect(retry.fullW).toBe(20); + expect(retry.fullH).toBe(10); + expect(mockFromUrl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx index 0995c78601..297d78cca9 100644 --- a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx +++ b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/dot-plot/index.test.jsx @@ -594,11 +594,13 @@ describe('Dot plot page', () => { // Wait briefly for effects to process await new Promise((resolve) => setTimeout(resolve, 100)); - // Count the getDotPlot calls made during reset - // With the fix, should be exactly 1 (from reset effect) - // Without the fix, would be 2 (one from marker genes sync, one from main effect) + // Count the getDotPlot calls made during reset. + // Reset preserves selectedGenes (keepValuesOnReset) and only flips useMarkerGenes + // back to false, so the displayed genes - and their plot data - are unchanged. The + // main effect therefore correctly skips refetching. The guarantee under test is that + // reset never triggers a redundant double fetch (it should be at most one call). const callsMadeForReset = fetchWork.mock.calls.length - callCountBeforeReset; - expect(callsMadeForReset).toBe(1); + expect(callsMadeForReset).toBeLessThanOrEqual(1); }); it('does not call getDotPlot when toggling to "Marker genes" without changing nMarkerGenes', async () => { diff --git a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/violin/__snapshots__/index.test.jsx.snap b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/violin/__snapshots__/index.test.jsx.snap index abf177a0b9..66d66b4e9e 100644 --- a/src/__test__/pages/experiments/[experimentId]/plots-and-tables/violin/__snapshots__/index.test.jsx.snap +++ b/src/__test__/pages/experiments/[experimentId]/plots-and-tables/violin/__snapshots__/index.test.jsx.snap @@ -61,7 +61,7 @@ exports[`ViolinIndex Adds a new plot to multi view: new-config 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, diff --git a/src/__test__/redux/actions/componentConfig/__snapshots__/loadPlotConfig.test.js.snap b/src/__test__/redux/actions/componentConfig/__snapshots__/loadPlotConfig.test.js.snap index 10bcff0173..c21d88fb11 100644 --- a/src/__test__/redux/actions/componentConfig/__snapshots__/loadPlotConfig.test.js.snap +++ b/src/__test__/redux/actions/componentConfig/__snapshots__/loadPlotConfig.test.js.snap @@ -57,7 +57,7 @@ exports[`loadPlotConfig Loads config properly 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, @@ -142,7 +142,7 @@ exports[`loadPlotConfig Loads default initial config for plots if plot config is }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, diff --git a/src/__test__/redux/reducers/componentConfig/__snapshots__/configsReplaced.test.js.snap b/src/__test__/redux/reducers/componentConfig/__snapshots__/configsReplaced.test.js.snap index 5d19236b3a..fe9c9857aa 100644 --- a/src/__test__/redux/reducers/componentConfig/__snapshots__/configsReplaced.test.js.snap +++ b/src/__test__/redux/reducers/componentConfig/__snapshots__/configsReplaced.test.js.snap @@ -219,7 +219,7 @@ exports[`configsReplaced Replaces existing state 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, @@ -305,7 +305,7 @@ exports[`configsReplaced Replaces existing state 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, @@ -391,7 +391,7 @@ exports[`configsReplaced Replaces existing state 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, @@ -470,7 +470,7 @@ exports[`configsReplaced Replaces existing state 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, @@ -570,7 +570,7 @@ exports[`configsReplaced Replaces existing state 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 20, diff --git a/src/__test__/redux/reducers/componentConfig/__snapshots__/updateConfig.test.js.snap b/src/__test__/redux/reducers/componentConfig/__snapshots__/updateConfig.test.js.snap index 045f9a0cf1..61e1078d46 100644 --- a/src/__test__/redux/reducers/componentConfig/__snapshots__/updateConfig.test.js.snap +++ b/src/__test__/redux/reducers/componentConfig/__snapshots__/updateConfig.test.js.snap @@ -62,7 +62,7 @@ exports[`updateConfig Checking if empty update doesnt change anything 1`] = ` }, "marker": { "opacity": 5, - "outline": true, + "outline": false, "shape": "circle", "showOpacity": true, "size": 5, diff --git a/src/__test__/redux/reducers/componentConfig/spatialInitialState.test.js b/src/__test__/redux/reducers/componentConfig/spatialInitialState.test.js new file mode 100644 index 0000000000..f34aca2deb --- /dev/null +++ b/src/__test__/redux/reducers/componentConfig/spatialInitialState.test.js @@ -0,0 +1,105 @@ +import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; + +// Invariants of the spatial local-outlier filter plot configs added on init-spatial. +describe('spatial outlier plot configs', () => { + const metricPlots = [ + 'spatialUmiOutlierPlot', + 'spatialNumGenesOutlierPlot', + 'spatialMitoOutlierPlot', + ]; + const highlightPlots = [ + 'spatialUmiOutlierHighlightPlot', + 'spatialNumGenesOutlierHighlightPlot', + 'spatialMitoOutlierHighlightPlot', + ]; + const histograms = [ + 'spatialUmiOutlierZscoreHistogram', + 'spatialNumGenesOutlierZscoreHistogram', + 'spatialMitoOutlierZscoreHistogram', + ]; + + it('registers every spatial outlier config in initialPlotConfigStates', () => { + [...metricPlots, ...highlightPlots, ...histograms].forEach((key) => { + expect(initialPlotConfigStates[key]).toBeDefined(); + }); + }); + + describe('metric slide configs', () => { + it('use the default gradient and hide the tissue image', () => { + metricPlots.forEach((key) => { + const config = initialPlotConfigStates[key]; + expect(config.colour.gradient).toBe('default'); + expect(config.showImage).toBe(false); + }); + }); + + it('repurpose shownGene as the colour-legend (metric) title', () => { + expect(initialPlotConfigStates.spatialUmiOutlierPlot.shownGene).toBe('UMIs'); + expect(initialPlotConfigStates.spatialNumGenesOutlierPlot.shownGene).toBe('Genes detected'); + expect(initialPlotConfigStates.spatialMitoOutlierPlot.shownGene).toBe('Mitochondrial %'); + }); + + it('reverse the colour bar for UMI and numGenes (low value = outlier = red)', () => { + expect(initialPlotConfigStates.spatialUmiOutlierPlot.colour.reverseCbar).toBe(true); + expect(initialPlotConfigStates.spatialNumGenesOutlierPlot.colour.reverseCbar).toBe(true); + }); + + it('does NOT reverse the colour bar for mito (high value = outlier = red)', () => { + expect(initialPlotConfigStates.spatialMitoOutlierPlot.colour.reverseCbar).toBeFalsy(); + }); + + it('clears keepValuesOnReset and disables value truncation', () => { + metricPlots.forEach((key) => { + const config = initialPlotConfigStates[key]; + expect(config.keepValuesOnReset).toEqual([]); + expect(config.truncatedValues).toBe(false); + }); + }); + }); + + describe('highlight slide configs', () => { + it('hide the tissue image', () => { + highlightPlots.forEach((key) => { + expect(initialPlotConfigStates[key].showImage).toBe(false); + }); + }); + + it('are independent objects from their metric-slide counterparts', () => { + expect(initialPlotConfigStates.spatialUmiOutlierHighlightPlot) + .not.toBe(initialPlotConfigStates.spatialUmiOutlierPlot); + }); + }); + + describe('z-score histogram configs', () => { + it('carry a default cutoff of 3', () => { + histograms.forEach((key) => { + expect(initialPlotConfigStates[key].cutoff).toBe(3); + }); + }); + + it('set a per-metric x-axis title and a Frequency y-axis title', () => { + expect(initialPlotConfigStates.spatialUmiOutlierZscoreHistogram.axes.xAxisText) + .toBe('UMI outlier z-score'); + expect(initialPlotConfigStates.spatialNumGenesOutlierZscoreHistogram.axes.xAxisText) + .toBe('Genes detected outlier z-score'); + expect(initialPlotConfigStates.spatialMitoOutlierZscoreHistogram.axes.xAxisText) + .toBe('Mitochondrial outlier z-score'); + histograms.forEach((key) => { + expect(initialPlotConfigStates[key].axes.yAxisText).toBe('Frequency'); + }); + }); + }); +}); + +// Plots & Tables spatial plots default selectedSample to null and auto-populate the +// first sample on mount. selectedSample must be in keepValuesOnReset so that write +// doesn't keep the Reset Plot button enabled after a reset. +describe('spatial Plots & Tables configs — reset behaviour', () => { + ['SpatialFeature', 'SpatialCategorical'].forEach((key) => { + it(`${key} keeps selectedSample on reset`, () => { + const config = initialPlotConfigStates[key]; + expect(config.selectedSample).toBeNull(); + expect(config.keepValuesOnReset).toEqual(expect.arrayContaining(['selectedSample'])); + }); + }); +}); diff --git a/src/__test__/utils/plotSpecs/generateSpatialCategoricalSpec.test.js b/src/__test__/utils/plotSpecs/generateSpatialCategoricalSpec.test.js new file mode 100644 index 0000000000..c8ddca0641 --- /dev/null +++ b/src/__test__/utils/plotSpecs/generateSpatialCategoricalSpec.test.js @@ -0,0 +1,145 @@ +import _ from 'lodash'; + +import { + generateSpec, generateData, filterCells, +} from 'utils/plotSpecs/generateSpatialCategoricalSpec'; +import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; +import { plotTypes } from 'utils/constants'; +import { + createHierarchyFromTree, + createPropertiesFromTree, +} from 'redux/reducers/cellSets/helpers'; + +const { cellSets: mockCellSets } = require('__test__/data/cell_sets.json'); + +const baseConfig = () => _.cloneDeep(initialPlotConfigStates[plotTypes.SPATIAL_CATEGORICAL]); +const imageData = { imageWidth: 1000, imageHeight: 500 }; +const legendsData = [ + { key: 'louvain-0', name: 'Cluster 0', color: '#111111' }, + { key: 'louvain-1', name: 'Cluster 1', color: '#222222' }, +]; + +describe('generateSpatialCategoricalSpec', () => { + describe('marks', () => { + it('renders the tissue image mark only when showImage is true', () => { + const tissueMark = (config) => generateSpec(config, 'mock', imageData, [], legendsData) + .marks.find((m) => m.type === 'image' && m.from.data === 'tissueImageData'); + + expect(tissueMark({ ...baseConfig(), showImage: true })).toBeDefined(); + expect(tissueMark({ ...baseConfig(), showImage: false })).toBeUndefined(); + }); + + it('renders the data-driven segmentation overlay (no dots) when hasSegmentation', () => { + const spec = generateSpec(baseConfig(), 'mock', imageData, [], legendsData, true); + const segMark = spec.marks.find( + (m) => m.type === 'image' && m.from.data === 'segOverlayData', + ); + const dotMark = spec.marks.find((m) => m.type === 'symbol'); + + expect(segMark).toBeDefined(); + expect(dotMark).toBeUndefined(); + }); + + it('falls back to centroid dots (from values) when there is no segmentation', () => { + const spec = generateSpec(baseConfig(), 'mock', imageData, [], legendsData, false); + const dotMark = spec.marks.find((m) => m.type === 'symbol'); + + expect(dotMark).toBeDefined(); + expect(dotMark.from.data).toBe('values'); + }); + }); + + describe('dimensions match the containing box', () => { + it('renders a mini plot at its (square) containing-box dimensions, regardless of slide aspect', () => { + const config = { ...baseConfig(), miniPlot: true }; + config.dimensions = { ...config.dimensions, width: 92, height: 92 }; + const spec = generateSpec(config, 'mock', { imageWidth: 1000, imageHeight: 500 }, [], legendsData); + + expect(spec.width).toBe(92); + expect(spec.height).toBe(92); + }); + + it('uses the configured dimensions verbatim for the full (non-mini) plot', () => { + const config = { ...baseConfig(), miniPlot: false }; + config.dimensions = { ...config.dimensions, width: 700, height: 550 }; + const spec = generateSpec(config, 'mock', { imageWidth: 1000, imageHeight: 500 }, [], legendsData); + + expect(spec.width).toBe(700); + expect(spec.height).toBe(550); + }); + }); + + describe('title omission on mini plots', () => { + it('omits the title on a mini plot', () => { + const spec = generateSpec({ ...baseConfig(), miniPlot: true }, 'mock', imageData, [], legendsData); + expect(spec.title).toBeUndefined(); + }); + + it('renders the title on the full plot', () => { + const spec = generateSpec({ ...baseConfig(), miniPlot: false }, 'mock', imageData, [], legendsData); + expect(spec.title).toBeDefined(); + }); + }); + + it('builds the cellSet colour scale range from the legends data', () => { + const spec = generateSpec(baseConfig(), 'mock', imageData, [], legendsData); + const labelColors = spec.scales.find(({ name }) => name === 'cellSetLabelColors'); + + expect(labelColors.range).toEqual(['#111111', '#222222']); + }); +}); + +describe('filterCells / generateData', () => { + let mockCellSetsReduxObject; + let mockEmbeddingData; + + beforeEach(() => { + mockCellSetsReduxObject = { + properties: createPropertiesFromTree(mockCellSets), + hierarchy: createHierarchyFromTree(mockCellSets), + }; + mockEmbeddingData = _.range(30).map((i) => [i, i + 1]); + }); + + it('filterCells produces a legend ordered by the cluster hierarchy', () => { + const { cellSetLegendsData } = filterCells(mockCellSetsReduxObject, 'All', 'louvain'); + + const expected = mockCellSets + .find(({ key }) => key === 'louvain').children + .map(({ key, name, color }) => ({ key, name, color })); + + expect(cellSetLegendsData).toEqual(expected); + }); + + it('filterCells restricted to a single sample yields a subset of clusters', () => { + const sampleKey = 'b62028a1-ffa0-4f10-823d-93c9ddb88898'; + const all = filterCells(mockCellSetsReduxObject, 'All', 'louvain'); + const sample = filterCells(mockCellSetsReduxObject, sampleKey, 'louvain'); + + expect(sample.cellSetLegendsData.length).toBeLessThanOrEqual(all.cellSetLegendsData.length); + expect(sample.cellSetLegendsData.length).toBeGreaterThan(0); + }); + + it('generateData attaches cluster key/name/colour and embedding coordinates', () => { + const { plotData, cellSetLegendsData } = generateData( + mockCellSetsReduxObject, 'All', 'louvain', mockEmbeddingData, + ); + + expect(cellSetLegendsData.length).toBeGreaterThan(0); + expect(plotData.length).toBeGreaterThan(0); + plotData.forEach((datum) => { + expect(datum).toHaveProperty('x'); + expect(datum).toHaveProperty('y'); + expect(datum).toHaveProperty('cellSetKey'); + expect(datum).toHaveProperty('cellSetName'); + expect(datum).toHaveProperty('color'); + }); + }); + + it('generateData drops cells with no embedding coordinate', () => { + const sparseEmbedding = _.range(30).map((i) => (i % 2 === 0 ? [i, i] : undefined)); + const { plotData } = generateData(mockCellSetsReduxObject, 'All', 'louvain', sparseEmbedding); + + plotData.forEach((datum) => expect(datum.x % 2).toBe(0)); + }); +}); diff --git a/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js b/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js new file mode 100644 index 0000000000..c9ced8375c --- /dev/null +++ b/src/__test__/utils/plotSpecs/generateSpatialFeatureSpec.test.js @@ -0,0 +1,189 @@ +import _ from 'lodash'; + +import { + generateSpec, generateData, filterCells, +} from 'utils/plotSpecs/generateSpatialFeatureSpec'; +import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; +import { plotTypes } from 'utils/constants'; +import { + createHierarchyFromTree, + createPropertiesFromTree, +} from 'redux/reducers/cellSets/helpers'; + +const { cellSets: mockCellSets } = require('__test__/data/cell_sets.json'); + +const baseConfig = () => _.cloneDeep(initialPlotConfigStates[plotTypes.SPATIAL_FEATURE]); +const imageData = { imageWidth: 1000, imageHeight: 500 }; + +describe('generateSpatialFeatureSpec', () => { + describe('marks', () => { + it('renders the tissue image mark only when showImage is true', () => { + const withImage = generateSpec({ ...baseConfig(), showImage: true }, 'mock', imageData, []); + const withoutImage = generateSpec({ ...baseConfig(), showImage: false }, 'mock', imageData, []); + + const tissueMark = (spec) => spec.marks.find( + (m) => m.type === 'image' && m.from.data === 'tissueImageData', + ); + expect(tissueMark(withImage)).toBeDefined(); + expect(tissueMark(withoutImage)).toBeUndefined(); + }); + + it('renders the data-driven segmentation overlay mark when hasSegmentation is true', () => { + const spec = generateSpec(baseConfig(), 'mock', imageData, [], true); + const segMark = spec.marks.find( + (m) => m.type === 'image' && m.from.data === 'segOverlayData', + ); + const dotMark = spec.marks.find((m) => m.type === 'symbol'); + + expect(segMark).toBeDefined(); + // overlay replaces the centroid-dot fallback + expect(dotMark).toBeUndefined(); + }); + + it('falls back to centroid dots when there is no segmentation', () => { + const spec = generateSpec(baseConfig(), 'mock', imageData, [], false); + const segMark = spec.marks.find( + (m) => m.type === 'image' && m.from.data === 'segOverlayData', + ); + const dotMark = spec.marks.find((m) => m.type === 'symbol'); + + expect(segMark).toBeUndefined(); + expect(dotMark).toBeDefined(); + expect(dotMark.from.data).toBe('plotData'); + }); + }); + + describe('colour scale: spectral-reverse XOR reverseCbar', () => { + const reverseFlag = (config) => generateSpec(config, 'mock', imageData, []) + .scales.find(({ name }) => name === 'color').reverse; + + it('spectral gradient defaults to reversed (no explicit reverseCbar)', () => { + const config = baseConfig(); + config.colour = { ...config.colour, gradient: 'spectral', reverseCbar: false }; + expect(reverseFlag(config)).toBe(true); + }); + + it('spectral + reverseCbar cancels out to NOT reversed (XOR)', () => { + const config = baseConfig(); + config.colour = { ...config.colour, gradient: 'spectral', reverseCbar: true }; + expect(reverseFlag(config)).toBe(false); + }); + + it('non-spectral gradient is not reversed by default', () => { + const config = baseConfig(); + config.colour = { ...config.colour, gradient: 'default', reverseCbar: false }; + expect(reverseFlag(config)).toBe(false); + }); + + it('non-spectral gradient + reverseCbar is reversed', () => { + const config = baseConfig(); + config.colour = { ...config.colour, gradient: 'default', reverseCbar: true }; + expect(reverseFlag(config)).toBe(true); + }); + }); + + describe('dimensions match the containing box', () => { + it('renders a mini plot at its (square) containing-box dimensions, regardless of slide aspect', () => { + const config = { ...baseConfig(), miniPlot: true }; + config.dimensions = { ...config.dimensions, width: 92, height: 92 }; + // a wide slide still fills the 92×92 mini-preview tile + const spec = generateSpec(config, 'mock', { imageWidth: 1000, imageHeight: 500 }, []); + + expect(spec.width).toBe(92); + expect(spec.height).toBe(92); + }); + + it('keeps the configured dimensions for the full (non-mini) plot', () => { + const config = { ...baseConfig(), miniPlot: false }; + config.dimensions = { ...config.dimensions, width: 700, height: 550 }; + const spec = generateSpec(config, 'mock', imageData, []); + + expect(spec.width).toBe(700); + expect(spec.height).toBe(550); + }); + }); + + describe('title and axes omission on mini plots', () => { + it('omits the title and padding on a mini plot', () => { + const config = { ...baseConfig(), miniPlot: true }; + const spec = generateSpec(config, 'mock', imageData, []); + + expect(spec.title).toBeUndefined(); + expect(spec.padding).toBe(0); + }); + + it('renders a title and fixed object padding on the full plot', () => { + const config = { ...baseConfig(), miniPlot: false }; + const spec = generateSpec(config, 'mock', imageData, []); + + expect(spec.title).toBeDefined(); + // fixed padding object (autosize:'none') so the data rect never reflows + expect(typeof spec.padding).toBe('object'); + expect(spec.autosize).toEqual({ type: 'none' }); + }); + + it('omits axes on a mini plot even when axis labels are enabled', () => { + const config = { ...baseConfig(), miniPlot: true }; + config.axes = { ...config.axes, xAxisLabels: true, yAxisLabels: true }; + const spec = generateSpec(config, 'mock', imageData, []); + + expect(spec.axes).toEqual([]); + }); + }); +}); + +describe('filterCells / generateData', () => { + let mockCellSetsReduxObject; + let mockEmbeddingData; + + beforeEach(() => { + mockCellSetsReduxObject = { + properties: createPropertiesFromTree(mockCellSets), + hierarchy: createHierarchyFromTree(mockCellSets), + }; + mockEmbeddingData = _.range(30).map((i) => [i, i + 1]); + }); + + it('filterCells returns a Set of every cell id when selectedSample is All', () => { + const result = filterCells(mockCellSetsReduxObject, 'All'); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBeGreaterThan(0); + }); + + it('filterCells returns only the chosen sample cell ids', () => { + const sampleKey = 'b62028a1-ffa0-4f10-823d-93c9ddb88898'; + const all = filterCells(mockCellSetsReduxObject, 'All'); + const sample = filterCells(mockCellSetsReduxObject, sampleKey); + + expect(sample.size).toBeGreaterThan(0); + expect(sample.size).toBeLessThanOrEqual(all.size); + // every sample cell is a subset of all cells + [...sample].forEach((id) => expect(all.has(id)).toBe(true)); + }); + + it('generateData maps filtered cell ids to {x, y, value} from embedding + plotData', () => { + const plotData = _.range(30).map((i) => i * 10); + const result = generateData(mockCellSetsReduxObject, 'All', plotData, mockEmbeddingData); + + expect(result.length).toBeGreaterThan(0); + result.forEach((datum) => { + expect(datum).toHaveProperty('x'); + expect(datum).toHaveProperty('y'); + expect(datum).toHaveProperty('value'); + }); + + // x/y come from the embedding coordinate, value from plotData[cellId] + const sample = result[0]; + expect(sample.y).toBe(sample.x + 1); + expect(sample.value).toBe(sample.x * 10); + }); + + it('generateData drops cells whose embedding coordinate is undefined', () => { + const sparseEmbedding = _.range(30).map((i) => (i % 2 === 0 ? [i, i] : undefined)); + const plotData = _.range(30).map((i) => i); + const result = generateData(mockCellSetsReduxObject, 'All', plotData, sparseEmbedding); + + // only even cell ids survive + result.forEach((datum) => expect(datum.x % 2).toBe(0)); + }); +}); diff --git a/src/__test__/utils/plotSpecs/generateZscoreHistogram.test.js b/src/__test__/utils/plotSpecs/generateZscoreHistogram.test.js new file mode 100644 index 0000000000..2af0241450 --- /dev/null +++ b/src/__test__/utils/plotSpecs/generateZscoreHistogram.test.js @@ -0,0 +1,152 @@ +import generateZscoreHistogram from 'utils/plotSpecs/generateZscoreHistogram'; +import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; + +// The z-score histogram config inherits doubletScoreHistogram + a cutoff of 3. +const baseConfig = () => ({ + ...initialPlotConfigStates.spatialUmiOutlierZscoreHistogram, +}); + +const makePlotData = (zscores) => zscores.map((zscore) => ({ zscore })); + +// Find the binned dataset's status formula transform expression. +const statusExpr = (spec) => spec.data + .find(({ name }) => name === 'binned') + .transform.find(({ as }) => as === 'status') + .expr; + +// Find the cutoff rule mark's x value. +const ruleX = (spec) => spec.marks.find(({ type }) => type === 'rule').encode.update.x.value; + +describe('generateZscoreHistogram', () => { + it('builds a valid vega spec with a binned dataset and a rule mark', () => { + const spec = generateZscoreHistogram(baseConfig(), makePlotData([-1, 0, 1])); + + expect(spec.$schema).toMatch(/vega\/v5/); + expect(spec.data.map(({ name }) => name)).toEqual(['plotData', 'binned']); + expect(spec.marks.map(({ type }) => type)).toEqual(['rect', 'rule']); + + const binTransform = spec.data + .find(({ name }) => name === 'binned') + .transform.find(({ type }) => type === 'bin'); + expect(binTransform.field).toBe('zscore'); + }); + + it('colours kept bars green and outlier bars red via an ordinal scale', () => { + const spec = generateZscoreHistogram(baseConfig(), makePlotData([0])); + const colorScale = spec.scales.find(({ name }) => name === 'color'); + + expect(colorScale.domain).toEqual(['kept', 'outlier']); + expect(colorScale.range).toEqual(['#2f9e44', 'red']); + }); + + describe('cutoff rule per direction', () => { + it('lower direction: flags bins below -cutoff and places the rule at -cutoff', () => { + const config = { ...baseConfig(), cutoff: 3 }; + const spec = generateZscoreHistogram(config, makePlotData([0]), 'lower'); + + expect(statusExpr(spec)).toBe("(datum.bin0 < -3) ? 'outlier' : 'kept'"); + expect(ruleX(spec)).toBe(-3); + }); + + it('upper direction: flags bins above +cutoff and places the rule at +cutoff', () => { + const config = { ...baseConfig(), cutoff: 3 }; + const spec = generateZscoreHistogram(config, makePlotData([0]), 'upper'); + + expect(statusExpr(spec)).toBe("(datum.bin1 > 3) ? 'outlier' : 'kept'"); + expect(ruleX(spec)).toBe(3); + }); + + it('respects a custom cutoff value', () => { + const config = { ...baseConfig(), cutoff: 2.5 }; + const spec = generateZscoreHistogram(config, makePlotData([0]), 'upper'); + + expect(statusExpr(spec)).toBe("(datum.bin1 > 2.5) ? 'outlier' : 'kept'"); + expect(ruleX(spec)).toBe(2.5); + }); + + it('defaults cutoff to 3 when omitted from the config', () => { + const config = baseConfig(); + delete config.cutoff; + const spec = generateZscoreHistogram(config, makePlotData([0]), 'lower'); + + expect(ruleX(spec)).toBe(-3); + }); + + it('defaults to the lower direction when none is given', () => { + const spec = generateZscoreHistogram(baseConfig(), makePlotData([0])); + expect(statusExpr(spec)).toContain('datum.bin0 < -3'); + }); + }); + + describe('z-score domain derivation', () => { + it('derives the bin extent from the data range when data is present', () => { + const spec = generateZscoreHistogram(baseConfig(), makePlotData([-5, 8])); + const binTransform = spec.data + .find(({ name }) => name === 'binned') + .transform.find(({ type }) => type === 'bin'); + + // extent spans the data; widened to include the cutoff if needed (here data + // already exceeds ±3). + expect(binTransform.extent).toEqual([-5, 8]); + }); + + it('always widens the extent so the cutoff rule is visible', () => { + // Data tighter than the cutoff → extent must still reach ±cutoff. + const spec = generateZscoreHistogram({ ...baseConfig(), cutoff: 3 }, makePlotData([-1, 1])); + const binTransform = spec.data + .find(({ name }) => name === 'binned') + .transform.find(({ type }) => type === 'bin'); + + expect(binTransform.extent[0]).toBeLessThanOrEqual(-3); + expect(binTransform.extent[1]).toBeGreaterThanOrEqual(3); + }); + + it('falls back to a ±2·cutoff extent when there is no data', () => { + const spec = generateZscoreHistogram({ ...baseConfig(), cutoff: 3 }, []); + const binTransform = spec.data + .find(({ name }) => name === 'binned') + .transform.find(({ type }) => type === 'bin'); + + expect(binTransform.extent).toEqual([-6, 6]); + }); + }); + + describe('axes ranges', () => { + it('uses the data extent for the x domain when xAxisAuto is true', () => { + const config = baseConfig(); + config.axesRanges = { ...config.axesRanges, xAxisAuto: true }; + const spec = generateZscoreHistogram(config, makePlotData([-4, 7])); + const xScale = spec.scales.find(({ name }) => name === 'xscale'); + + expect(xScale.domain).toEqual([-4, 7]); + }); + + it('uses the configured min/max for the x domain when xAxisAuto is false', () => { + const config = baseConfig(); + config.axesRanges = { + ...config.axesRanges, xAxisAuto: false, xMin: -10, xMax: 10, + }; + const spec = generateZscoreHistogram(config, makePlotData([-4, 7])); + const xScale = spec.scales.find(({ name }) => name === 'xscale'); + + expect(xScale.domain).toEqual([-10, 10]); + }); + }); + + it('omits the legend when the legend is disabled', () => { + const config = baseConfig(); + config.legend = { ...config.legend, enabled: false }; + const spec = generateZscoreHistogram(config, makePlotData([0])); + + expect(spec.legends).toBeNull(); + }); + + it('renders the legend when enabled', () => { + const config = baseConfig(); + config.legend = { ...config.legend, enabled: true, position: 'top' }; + const spec = generateZscoreHistogram(config, makePlotData([0])); + + expect(spec.legends).toHaveLength(1); + expect(spec.legends[0].direction).toBe('horizontal'); + }); +}); diff --git a/src/__test__/utils/plotSpecs/spatialZoomSignals.test.js b/src/__test__/utils/plotSpecs/spatialZoomSignals.test.js new file mode 100644 index 0000000000..860ff9fe34 --- /dev/null +++ b/src/__test__/utils/plotSpecs/spatialZoomSignals.test.js @@ -0,0 +1,20 @@ +import spatialZoomSignals from 'utils/plotSpecs/spatialZoomSignals'; + +describe('spatialZoomSignals — interactive vs mini', () => { + it('seeds xdom/ydom from the initial domains for mini previews (no handlers)', () => { + const signals = spatialZoomSignals([1, 2], [3, 4], [0, 10], [0, 10], false); + const byName = Object.fromEntries(signals.map((s) => [s.name, s])); + expect(byName.xdom.value).toEqual([1, 2]); + expect(byName.ydom.value).toEqual([3, 4]); + // no wheel/drag handlers in the non-interactive variant + expect(byName.xdom.on).toBeUndefined(); + }); + + it('drives xdom off initXdom with wheel/drag handlers when interactive', () => { + const signals = spatialZoomSignals([1, 2], [3, 4], [0, 10], [0, 10], true); + const byName = Object.fromEntries(signals.map((s) => [s.name, s])); + expect(byName.initXdom.value).toEqual([1, 2]); + expect(byName.xdom.update).toBe('initXdom'); + expect(byName.xdom.on.length).toBeGreaterThan(0); + }); +}); diff --git a/src/__test__/utils/plotUtils.test.js b/src/__test__/utils/plotUtils.test.js index 8fb62299df..80dc16ff98 100644 --- a/src/__test__/utils/plotUtils.test.js +++ b/src/__test__/utils/plotUtils.test.js @@ -1,4 +1,4 @@ -import { hexToRgb, convertRange } from 'utils/plotUtils'; +import { hexToRgb, convertRange, offsetCentroids } from 'utils/plotUtils'; describe('hexToRgb', () => { it('converts a hex to array of [r, g, b]', () => { @@ -30,3 +30,68 @@ describe('convertRange', () => { expect(res).toEqual(values); }); }); + +describe('offsetCentroids', () => { + // Two samples laid out in a single row of two columns. perImageShape is + // [height, width]; the second sample is shifted right by one image width. + const sampleIds = ['sample-0', 'sample-1']; + const properties = { + 'sample-0': { cellIds: new Set([0, 1]) }, + 'sample-1': { cellIds: new Set([2, 3]) }, + }; + const perImageShape = [100, 200]; // [imageHeight, imageWidth] + const gridShape = [1, 2]; // [rows, columns] + + it('applies the per-sample grid offset (column * width, row * height)', () => { + const results = []; + results[0] = [10, 20]; // sample-0, no offset + results[2] = [5, 5]; // sample-1, shifted right by one image width + + const offset = offsetCentroids(results, properties, sampleIds, perImageShape, gridShape); + + expect(offset[0]).toEqual([10, 20]); + expect(offset[2]).toEqual([205, 5]); + }); + + it('leaves null/undefined (filtered) cells as holes, producing a sparse array', () => { + const results = []; + results[0] = [10, 20]; + results[1] = null; // QC-filtered cell: null in the worker result + results[2] = [5, 5]; + // index 3 never assigned (sparse hole) + + const offset = offsetCentroids(results, properties, sampleIds, perImageShape, gridShape); + + expect(1 in offset).toBe(false); + expect(3 in offset).toBe(false); + expect(offset[0]).toBeDefined(); + expect(offset[2]).toBeDefined(); + }); + + it('does not throw on [NaN, NaN] cells (filtered cells serialised as NaN)', () => { + const results = []; + results[0] = [10, 20]; + results[3] = [NaN, NaN]; // filtered cell arriving as NaN from R->python + + let offset; + expect(() => { + offset = offsetCentroids(results, properties, sampleIds, perImageShape, gridShape); + }).not.toThrow(); + + // the NaN cell is offset but stays non-finite (downstream cellsInAnyCluster skips it) + expect(offset[3].every((v) => Number.isNaN(v))).toBe(true); + }); + + it('skips cells that do not belong to any sample without throwing', () => { + const results = []; + results[0] = [10, 20]; + results[9] = [1, 1]; // cell id not in any sample's cellIds + + let offset; + expect(() => { + offset = offsetCentroids(results, properties, sampleIds, perImageShape, gridShape); + }).not.toThrow(); + + expect(9 in offset).toBe(false); + }); +}); diff --git a/src/__test__/utils/spatial/deckPlotChrome.test.js b/src/__test__/utils/spatial/deckPlotChrome.test.js new file mode 100644 index 0000000000..f53f2db1b4 --- /dev/null +++ b/src/__test__/utils/spatial/deckPlotChrome.test.js @@ -0,0 +1,122 @@ +import { + niceTicks, formatTick, isDarkColor, chromeToSvg, drawChromeToCanvas, +} from 'utils/spatial/deckPlotChrome'; + +describe('niceTicks', () => { + it('returns evenly spaced round ticks within the range', () => { + const ticks = niceTicks(0, 100, 6); + expect(ticks).toEqual([0, 20, 40, 60, 80, 100]); + }); + + it('starts at the first nice multiple inside the range', () => { + const ticks = niceTicks(12, 47, 5); + // step 10 → first multiple ≥ 12 is 20 + expect(ticks[0]).toBe(20); + expect(ticks[ticks.length - 1]).toBeLessThanOrEqual(47); + }); + + it('collapses floating-point noise', () => { + const ticks = niceTicks(0, 1, 6); + expect(ticks).toContain(0.6); // not 0.6000000000000001 + }); + + it('returns [] for a degenerate or non-finite range', () => { + expect(niceTicks(5, 5)).toEqual([]); + expect(niceTicks(10, 0)).toEqual([]); + expect(niceTicks(NaN, 10)).toEqual([]); + }); +}); + +describe('formatTick', () => { + it('rounds large magnitudes to integers and trims small ones', () => { + expect(formatTick(1234.5)).toBe('1235'); + expect(formatTick(2)).toBe('2'); + expect(formatTick(0.25)).toBe('0.3'); + }); +}); + +describe('isDarkColor', () => { + it('detects dark vs light backgrounds', () => { + expect(isDarkColor('#000000')).toBe(true); + expect(isDarkColor('#222222')).toBe(true); + expect(isDarkColor('#FFFFFF')).toBe(false); + expect(isDarkColor('#cccccc')).toBe(false); + expect(isDarkColor(undefined)).toBe(false); + }); +}); + +describe('chromeToSvg', () => { + const model = { + rects: [{ + x: 1, y: 2, w: 3, h: 4, fill: '#ff0000', + }], + lines: [{ + x1: 0, y1: 0, x2: 10, y2: 0, stroke: '#000000', width: 1, + }], + texts: [{ + x: 5, y: 6, text: 'Gad1 & Sst', anchor: 'middle', size: 12, color: '#000000', + }], + }; + + it('serialises the model + GL snapshot to an string', () => { + const svg = chromeToSvg(model, { + width: 100, + height: 80, + background: '#FFFFFF', + imageHref: 'data:image/png;base64,AAAA', + imageRect: [10, 10, 80, 60], + }); + expect(svg.startsWith('')).toBe(true); + }); +}); + +describe('drawChromeToCanvas', () => { + it('draws every primitive onto the 2D context, scaled', () => { + const calls = []; + const ctx = { + globalAlpha: 1, + fillStyle: '', + strokeStyle: '', + lineWidth: 0, + font: '', + textAlign: '', + textBaseline: '', + fillRect: (...a) => calls.push(['fillRect', ...a]), + strokeRect: (...a) => calls.push(['strokeRect', ...a]), + beginPath: () => calls.push(['beginPath']), + moveTo: (...a) => calls.push(['moveTo', ...a]), + lineTo: (...a) => calls.push(['lineTo', ...a]), + stroke: () => calls.push(['stroke']), + fillText: (...a) => calls.push(['fillText', ...a]), + save: () => {}, + restore: () => {}, + translate: () => {}, + rotate: () => {}, + }; + drawChromeToCanvas(ctx, { + rects: [{ + x: 1, y: 1, w: 2, h: 2, fill: '#fff', + }], + lines: [{ + x1: 0, y1: 0, x2: 4, y2: 0, stroke: '#000', + }], + texts: [{ + x: 3, y: 3, text: 'X', color: '#000', size: 10, + }], + }, 2); + + // rect drawn at 2× scale + expect(calls).toContainEqual(['fillRect', 2, 2, 4, 4]); + // line endpoints at 2× scale + expect(calls).toContainEqual(['moveTo', 0, 0]); + expect(calls).toContainEqual(['lineTo', 8, 0]); + expect(calls).toContainEqual(['fillText', 'X', 6, 6]); + }); +}); diff --git a/src/__test__/utils/spatial/loadMoleculeNodes.test.js b/src/__test__/utils/spatial/loadMoleculeNodes.test.js new file mode 100644 index 0000000000..61d3855c76 --- /dev/null +++ b/src/__test__/utils/spatial/loadMoleculeNodes.test.js @@ -0,0 +1,137 @@ +import { + tableFromArrays, tableToIPC, compressionRegistry, CompressionType, +} from 'apache-arrow'; +import loadMoleculeNodes, { + loadMoleculeMeta, +} from 'utils/spatial/loadMoleculeNodes'; + +// Build a Feather (Arrow IPC file) buffer for a per-gene table. +const feather = (cols) => tableToIPC(tableFromArrays(cols), 'file'); + +// meta.json mirrors the contract: dense genes array indexed by feature_code, each +// entry naming its own per-gene Feather file. +const META = { + version: 2, + qvThreshold: 20, + rootExtent: { x: [0, 10], y: [0, 10] }, + genes: [ + { + code: 0, gene: 'Gad1', entry: '0.feather', nPoints: 2, + }, + { + code: 1, gene: 'Sst', entry: '1.feather', nPoints: 2, + }, + { + code: 2, gene: 'Pvalb', entry: '2.feather', nPoints: 1, + }, + ], +}; + +// One Feather entry per gene: x/y only (the entry IS the gene). +const buildStore = (overrides = {}) => { + const entries = { + 'meta.json': new TextEncoder().encode(JSON.stringify(META)), + '0.feather': feather({ + x: Float64Array.from([2, 1]), + y: Float64Array.from([2, 1]), + }), + '1.feather': feather({ + x: Float64Array.from([8, 9]), + y: Float64Array.from([8, 9]), + }), + '2.feather': feather({ + x: Float64Array.from([3]), + y: Float64Array.from([3]), + }), + ...overrides, + }; + + const get = jest.fn(async (key) => entries[key.replace(/^\//, '')]); + return { get, entries, getMock: get }; +}; + +describe('loadMoleculeNodes', () => { + it('caches meta.json per store (read once)', async () => { + const store = buildStore(); + await loadMoleculeNodes(store, { genes: [0] }); + await loadMoleculeNodes(store, { genes: [0] }); + + const metaReads = store.getMock.mock.calls.filter(([k]) => k.includes('meta.json')); + expect(metaReads).toHaveLength(1); + }); + + it('reads only the requested gene entries, never the others', async () => { + const store = buildStore(); + const { + x, y, featureCode, count, + } = await loadMoleculeNodes(store, { genes: [0] }); + + expect(count).toBe(2); + expect(Array.from(x)).toEqual([2, 1]); + expect(Array.from(y)).toEqual([2, 1]); + expect(Array.from(featureCode)).toEqual([0, 0]); + + // the other genes' entries must never be range-read + expect(store.getMock).toHaveBeenCalledWith('/0.feather'); + expect(store.getMock).not.toHaveBeenCalledWith('/1.feather'); + expect(store.getMock).not.toHaveBeenCalledWith('/2.feather'); + }); + + it('concatenates multiple genes, tagging each point with its feature_code', async () => { + const store = buildStore(); + const { count, featureCode } = await loadMoleculeNodes(store, { genes: [0, 1] }); + expect(count).toBe(4); + expect(Array.from(featureCode)).toEqual([0, 0, 1, 1]); + }); + + it('loads every gene when none are requested', async () => { + const store = buildStore(); + const { count } = await loadMoleculeNodes(store, {}); + // 2 (Gad1) + 2 (Sst) + 1 (Pvalb) + expect(count).toBe(5); + }); + + it('ignores requested codes absent from the dictionary', async () => { + const store = buildStore(); + const { count } = await loadMoleculeNodes(store, { genes: [0, 99] }); + expect(count).toBe(2); + }); + + it('caches a parsed gene entry (re-selecting it does not re-read)', async () => { + const store = buildStore(); + await loadMoleculeNodes(store, { genes: [0] }); + await loadMoleculeNodes(store, { genes: [0] }); + const geneReads = store.getMock.mock.calls.filter(([k]) => k.includes('0.feather')); + expect(geneReads).toHaveLength(1); + }); + + it('drops non-finite coordinates (Phase-1 guard)', async () => { + const store = buildStore({ + '0.feather': feather({ + x: Float64Array.from([2, NaN]), + y: Float64Array.from([2, 8]), + }), + }); + const { count, x } = await loadMoleculeNodes(store, { genes: [0] }); + expect(count).toBe(1); + expect(Array.from(x)).toEqual([2]); + }); +}); + +describe('ZSTD codec registration', () => { + it('registers a ZSTD decoder when the reader module loads', () => { + // importing loadMoleculeNodes (top of file) pulls in registerArrowZstd + const codec = compressionRegistry.get(CompressionType.ZSTD); + expect(codec).toBeTruthy(); + expect(typeof codec.decode).toBe('function'); + }); +}); + +describe('loadMoleculeMeta', () => { + it('returns the parsed meta dictionary', async () => { + const store = buildStore(); + const meta = await loadMoleculeMeta(store); + expect(meta.genes).toHaveLength(3); + expect(meta.genes[1].gene).toBe('Sst'); + }); +}); diff --git a/src/__test__/utils/spatial/moleculeColors.test.js b/src/__test__/utils/spatial/moleculeColors.test.js new file mode 100644 index 0000000000..2d2f6b5e8c --- /dev/null +++ b/src/__test__/utils/spatial/moleculeColors.test.js @@ -0,0 +1,55 @@ +import { + MOLECULE_PALETTE, resolveGeneColors, MAX_MOLECULE_GENES, +} from 'utils/spatial/moleculeColors'; + +describe('resolveGeneColors', () => { + it('allocates the first available palette colours in order', () => { + expect(resolveGeneColors(['A', 'B', 'C'], {})).toEqual({ + A: MOLECULE_PALETTE[0], + B: MOLECULE_PALETTE[1], + C: MOLECULE_PALETTE[2], + }); + }); + + it('reserves existing/override colours so auto-genes skip them', () => { + const colors = resolveGeneColors(['A', 'B', 'C'], { B: MOLECULE_PALETTE[0] }); + expect(colors).toEqual({ + A: MOLECULE_PALETTE[1], + B: MOLECULE_PALETTE[0], + C: MOLECULE_PALETTE[2], + }); + expect(new Set(Object.values(colors)).size).toBe(3); + }); + + it('does not recolour existing genes when a new one is appended', () => { + // existing genes carry persisted (reserved) colours + const existing = { A: MOLECULE_PALETTE[0], B: MOLECULE_PALETTE[1] }; + const after = resolveGeneColors(['A', 'B', 'C'], existing); + expect(after.A).toBe(MOLECULE_PALETTE[0]); + expect(after.B).toBe(MOLECULE_PALETTE[1]); + expect(after.C).toBe(MOLECULE_PALETTE[2]); + }); + + it('reuses a freed colour: removing a gene then adding another grabs the first available', () => { + // A,B,C seeded to palette[0,1,2]; remove B (its colour freed), add D + const afterRemoveB = { A: MOLECULE_PALETTE[0], C: MOLECULE_PALETTE[2] }; + const colors = resolveGeneColors(['A', 'C', 'D'], afterRemoveB); + // D takes palette[1] — the first available (B's freed slot), not palette[3] + expect(colors.D).toBe(MOLECULE_PALETTE[1]); + }); + + it('treats a null entry as "no override" and allocates a colour (removed-gene sentinel)', () => { + // removeGene nulls the key (the config reducer can't delete it); null must not + // be treated as an override or reserved. + expect(resolveGeneColors(['A', 'B'], { A: null })).toEqual({ + A: MOLECULE_PALETTE[0], + B: MOLECULE_PALETTE[1], + }); + }); + + it('keeps every allocated colour distinct up to the gene cap', () => { + const genes = Array.from({ length: MAX_MOLECULE_GENES }, (_, i) => `g${i}`); + const colors = resolveGeneColors(genes, {}); + expect(new Set(Object.values(colors)).size).toBe(MAX_MOLECULE_GENES); + }); +}); diff --git a/src/__test__/utils/spatialMoleculesRegistration.test.js b/src/__test__/utils/spatialMoleculesRegistration.test.js new file mode 100644 index 0000000000..1f582ce1ef --- /dev/null +++ b/src/__test__/utils/spatialMoleculesRegistration.test.js @@ -0,0 +1,44 @@ +import { + plotTypes, plotNames, plotUuids, spatialPlotTypes, spatialPlotNames, + moleculeTechs, sampleTech, +} from 'utils/constants'; +import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; + +describe('Spatial Molecules plot type registration', () => { + it('defines a SPATIAL_MOLECULES plot type with matching name + uuid', () => { + expect(plotTypes.SPATIAL_MOLECULES).toBe('SpatialMolecules'); + expect(plotNames.SPATIAL_MOLECULES).toBeDefined(); + expect(plotUuids.SPATIAL_MOLECULES).toBeDefined(); + }); + + it('lists the plot type / name among the spatial plots', () => { + expect(spatialPlotTypes).toContain(plotTypes.SPATIAL_MOLECULES); + expect(spatialPlotNames).toContain(plotNames.SPATIAL_MOLECULES); + }); + + it('registers an initial config with selectedGenes and marker fields', () => { + const config = initialPlotConfigStates[plotTypes.SPATIAL_MOLECULES]; + expect(config).toBeDefined(); + expect(config.selectedGenes).toEqual([]); + expect(config.nmols).toBeUndefined(); + expect(config.marker).toBeDefined(); + expect(typeof config.marker.size).toBe('number'); + expect(typeof config.marker.opacity).toBe('number'); + }); + + it('defaults the segmentation outlines on and an empty per-gene colour map', () => { + const config = initialPlotConfigStates[plotTypes.SPATIAL_MOLECULES]; + expect(config.showSegmentationOutlines).toBe(true); + expect(config.geneColors).toEqual({}); + // the auto-populated sample/gene/colour fields survive a reset + expect(config.keepValuesOnReset).toEqual( + expect.arrayContaining(['selectedSample', 'selectedGenes', 'geneColors']), + ); + }); + + it('gates the plot to molecule-capable techs (Xenium)', () => { + expect(moleculeTechs).toContain(sampleTech.XENIUM); + // not offered for a non-spatial tech + expect(moleculeTechs).not.toContain(sampleTech['10X']); + }); +}); diff --git a/src/__test__/utils/spatialQcConstants.test.js b/src/__test__/utils/spatialQcConstants.test.js new file mode 100644 index 0000000000..b9705a7180 --- /dev/null +++ b/src/__test__/utils/spatialQcConstants.test.js @@ -0,0 +1,58 @@ +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { qcSteps, getUserFriendlyQCStepName } from 'utils/qcSteps'; +import { spatialPlotTypes, plotTypes } from 'utils/constants'; +import updateFilterSettings from 'redux/actions/experimentSettings/processingConfig/updateFilterSettings'; +import { EXPERIMENT_SETTINGS_SAMPLE_FILTER_UPDATE } from 'redux/actionTypes/experimentSettings'; + +const spatialSteps = ['spatialUmiOutlier', 'spatialNumGenesOutlier', 'spatialMitoOutlier']; + +describe('qcSteps', () => { + it('includes the three spatial outlier steps in canonical order', () => { + spatialSteps.forEach((step) => expect(qcSteps).toContain(step)); + + // they sit together, after doubletScores and before dataIntegration + const idx = (s) => qcSteps.indexOf(s); + expect(idx('spatialUmiOutlier')).toBeGreaterThan(idx('doubletScores')); + expect(idx('spatialMitoOutlier')).toBeLessThan(idx('dataIntegration')); + expect(idx('spatialNumGenesOutlier')).toBe(idx('spatialUmiOutlier') + 1); + expect(idx('spatialMitoOutlier')).toBe(idx('spatialNumGenesOutlier') + 1); + }); + + it('maps each spatial step to a user-friendly name', () => { + expect(getUserFriendlyQCStepName('spatialUmiOutlier')).toBe('UMI filter'); + expect(getUserFriendlyQCStepName('spatialNumGenesOutlier')).toBe('Number of genes filter'); + expect(getUserFriendlyQCStepName('spatialMitoOutlier')).toBe('Mitochondrial content filter'); + }); +}); + +describe('spatialPlotTypes constant', () => { + it('contains exactly the spatial plot types', () => { + expect(spatialPlotTypes).toEqual([ + plotTypes.SPATIAL_CATEGORICAL, + plotTypes.SPATIAL_FEATURE, + plotTypes.SPATIAL_MOLECULES, + ]); + }); +}); + +describe('updateFilterSettings — spatial steps are sample-specific', () => { + const mockStore = configureStore([thunk]); + + it.each(spatialSteps)('dispatches a SAMPLE filter update for %s with a sampleId', (step) => { + const store = mockStore({}); + store.dispatch(updateFilterSettings(step, { foo: 'bar' }, 'sample-1')); + + const actions = store.getActions(); + expect(actions).toHaveLength(1); + expect(actions[0].type).toBe(EXPERIMENT_SETTINGS_SAMPLE_FILTER_UPDATE); + expect(actions[0].payload).toMatchObject({ step, sampleId: 'sample-1' }); + }); + + it.each(spatialSteps)('throws for %s when no sampleId is supplied', (step) => { + const store = mockStore({}); + expect(() => store.dispatch(updateFilterSettings(step, { foo: 'bar' }))) + .toThrow(`sampleId is undefined, but step: ${step} received needs a sampleId`); + }); +}); diff --git a/src/__test__/utils/upload/validateXenium.test.js b/src/__test__/utils/upload/validateXenium.test.js new file mode 100644 index 0000000000..bdee14e204 --- /dev/null +++ b/src/__test__/utils/upload/validateXenium.test.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import validateXenium from 'utils/upload/validateXenium'; +import sampleFileType from 'utils/sampleFileType'; + +// minimal Blob-like stub: validateXenium only uses .slice().arrayBuffer() and .size +const makeBlob = (data) => ({ + size: data.length, + slice(start, end) { return makeBlob(data.slice(start, end)); }, + arrayBuffer() { return Promise.resolve(data.buffer); }, +}); + +const HDF5_MAGIC = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]; +const PARQUET_MAGIC = [0x50, 0x41, 0x52, 0x31]; // "PAR1" + +// a valid parquet blob has the PAR1 marker at both ends with arbitrary body +const makeParquet = () => makeBlob( + new Uint8Array([...PARQUET_MAGIC, 1, 2, 3, 4, 5, ...PARQUET_MAGIC]), +); +const makeH5 = () => makeBlob(new Uint8Array([...HDF5_MAGIC, 9, 9, 9, 9])); + +const makeSample = () => ({ + files: { + [sampleFileType.XENIUM_CELL_FEATURE_MATRIX]: { fileObject: makeH5() }, + [sampleFileType.XENIUM_CELLS]: { fileObject: makeParquet() }, + [sampleFileType.XENIUM_CELL_BOUNDARIES]: { fileObject: makeParquet() }, + [sampleFileType.XENIUM_TRANSCRIPTS]: { fileObject: makeParquet() }, + }, +}); + +describe('validateXenium', () => { + it('resolves for a valid Xenium sample', async () => { + await expect(validateXenium(makeSample())).resolves.toBeUndefined(); + }); + + it('throws when a required file is missing', async () => { + const sample = makeSample(); + delete sample.files[sampleFileType.XENIUM_CELLS]; + await expect(validateXenium(sample)).rejects.toThrow(/cells\.parquet/); + }); + + it('throws when the count matrix is not a valid HDF5 file', async () => { + const sample = makeSample(); + sample.files[sampleFileType.XENIUM_CELL_FEATURE_MATRIX].fileObject = makeBlob( + new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]), + ); + await expect(validateXenium(sample)).rejects.toThrow(/valid HDF5/); + }); + + it('throws when a parquet file lacks the PAR1 marker', async () => { + const sample = makeSample(); + sample.files[sampleFileType.XENIUM_CELL_BOUNDARIES].fileObject = makeBlob( + new Uint8Array([...PARQUET_MAGIC, 1, 2, 3, 0, 0, 0, 0]), // bad tail + ); + await expect(validateXenium(sample)).rejects.toThrow(/valid Parquet/); + }); + + it('does not mutate the input sample', async () => { + const sample = makeSample(); + const clone = _.cloneDeep(sample); + await validateXenium(sample); + expect(Object.keys(sample.files)).toEqual(Object.keys(clone.files)); + }); + + it('throws when the required transcripts.parquet is absent', async () => { + const sample = makeSample(); + delete sample.files[sampleFileType.XENIUM_TRANSCRIPTS]; + await expect(validateXenium(sample)).rejects.toThrow(/transcripts\.parquet/); + }); + + it('throws when transcripts.parquet is not valid Parquet', async () => { + const sample = makeSample(); + sample.files[sampleFileType.XENIUM_TRANSCRIPTS] = { + fileObject: makeBlob(new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7])), + }; + await expect(validateXenium(sample)).rejects.toThrow(/valid Parquet/); + }); +}); diff --git a/src/__test__/utils/upload/xeniumRegistration.test.js b/src/__test__/utils/upload/xeniumRegistration.test.js new file mode 100644 index 0000000000..105d7f19d3 --- /dev/null +++ b/src/__test__/utils/upload/xeniumRegistration.test.js @@ -0,0 +1,117 @@ +import { + sampleTech, spatialTechs, imagelessTechs, obj2sTechs, +} from 'utils/constants'; +import sampleFileType, { fileTypeToDisplay, fileTypeColumnWidth } from 'utils/sampleFileType'; +import fileUploadUtils, { techNamesToDisplay } from 'utils/upload/fileUploadUtils'; +import { inspectFile, Verdict } from 'utils/upload/fileInspector'; +import validateXenium from 'utils/upload/validateXenium'; +import sampleValidators from 'utils/upload/sampleValidators'; + +// Locks in the Xenium upload registration added in Phase 1. These guard the +// "a new file type touches several coordinated places" gotcha (plan learning 5). +describe('Xenium registration — constants', () => { + it('registers XENIUM in the sampleTech enum', () => { + expect(sampleTech.XENIUM).toEqual('xenium'); + }); + + it('marks xenium as a spatial technology', () => { + expect(spatialTechs).toContain(sampleTech.XENIUM); + }); + + it('marks xenium as an imageless technology (no tissue image)', () => { + expect(imagelessTechs).toContain(sampleTech.XENIUM); + }); + + it('does not treat xenium as an obj2s (pre-processed object) technology', () => { + expect(obj2sTechs).not.toContain(sampleTech.XENIUM); + }); +}); + +describe('Xenium registration — sampleFileType', () => { + const xeniumTypes = [ + [sampleFileType.XENIUM_CELL_FEATURE_MATRIX, 'xenium_cell_feature_matrix', 'cell_feature_matrix.h5'], + [sampleFileType.XENIUM_CELLS, 'xenium_cells', 'cells.parquet'], + [sampleFileType.XENIUM_CELL_BOUNDARIES, 'xenium_cell_boundaries', 'cell_boundaries.parquet'], + [sampleFileType.XENIUM_TRANSCRIPTS, 'xenium_transcripts', 'transcripts.parquet'], + ]; + + it.each(xeniumTypes)('defines the file type constant %s', (constant, expectedValue) => { + expect(constant).toEqual(expectedValue); + }); + + it.each(xeniumTypes)('maps %s to the on-disk display name %s', (constant, _value, displayName) => { + expect(fileTypeToDisplay[constant]).toEqual(displayName); + }); + + it.each(xeniumTypes)('defines a column width for %s', (constant) => { + expect(typeof fileTypeColumnWidth[constant]).toEqual('number'); + }); +}); + +describe('Xenium registration — fileUploadUtils', () => { + const xeniumOptions = fileUploadUtils[sampleTech.XENIUM]; + + it('registers a display name for the technology', () => { + expect(techNamesToDisplay[sampleTech.XENIUM]).toEqual('Xenium'); + }); + + it('is a folder-per-sample spatial count matrix tech', () => { + expect(xeniumOptions.category).toEqual('SPATIAL - Sample Count Matrices'); + expect(xeniumOptions.webkitdirectory).toEqual(''); + }); + + it('accepts the four required Xenium files', () => { + expect(Array.from(xeniumOptions.acceptedFiles).sort()).toEqual( + ['cell_boundaries.parquet', 'cell_feature_matrix.h5', 'cells.parquet', 'transcripts.parquet'], + ); + }); + + it('requires the four Xenium file types', () => { + expect(xeniumOptions.requiredFiles).toEqual([ + sampleFileType.XENIUM_CELL_FEATURE_MATRIX, + sampleFileType.XENIUM_CELLS, + sampleFileType.XENIUM_CELL_BOUNDARIES, + sampleFileType.XENIUM_TRANSCRIPTS, + ]); + }); + + it.each([ + ['cell_feature_matrix.h5', sampleFileType.XENIUM_CELL_FEATURE_MATRIX], + ['cells.parquet', sampleFileType.XENIUM_CELLS], + ['cell_boundaries.parquet', sampleFileType.XENIUM_CELL_BOUNDARIES], + ['transcripts.parquet', sampleFileType.XENIUM_TRANSCRIPTS], + ['some/nested/path/cells.parquet', sampleFileType.XENIUM_CELLS], + ['some/nested/path/transcripts.parquet', sampleFileType.XENIUM_TRANSCRIPTS], + ])('maps filename %s to file type %s', (fileName, expectedType) => { + expect(xeniumOptions.getCorrespondingType(fileName)).toEqual(expectedType); + }); + + it('validates accepted file names and rejects others', () => { + expect(xeniumOptions.isNameValid('cells.parquet')).toBe(true); + expect(xeniumOptions.isNameValid('cell_feature_matrix.h5')).toBe(true); + expect(xeniumOptions.isNameValid('transcripts.parquet')).toBe(true); + expect(xeniumOptions.isNameValid('analysis.csv')).toBe(false); + }); +}); + +describe('Xenium registration — fileInspector', () => { + it.each([ + 'cell_feature_matrix.h5', + 'cells.parquet', + 'cell_boundaries.parquet', + 'transcripts.parquet', + ])('treats %s as already-compressed (VALID_ZIPPED, skip gzip)', async (name) => { + expect(await inspectFile({ name }, sampleTech.XENIUM)).toEqual(Verdict.VALID_ZIPPED); + }); + + it('rejects an unrecognised file name for xenium', async () => { + expect(await inspectFile({ name: 'analysis.csv' }, sampleTech.XENIUM)) + .toEqual(Verdict.INVALID_NAME); + }); +}); + +describe('Xenium registration — validator', () => { + it('uses the Xenium content validator', () => { + expect(sampleValidators[sampleTech.XENIUM]).toBe(validateXenium); + }); +}); diff --git a/src/components/ContentWrapper.jsx b/src/components/ContentWrapper.jsx index 092ade351d..3d660c8a67 100644 --- a/src/components/ContentWrapper.jsx +++ b/src/components/ContentWrapper.jsx @@ -351,6 +351,7 @@ const ContentWrapper = (props) => { pipelineType={currentStatusScreen.type} pipelineErrorMessage={currentStatusScreen?.message} completedSteps={currentStatusScreen?.completedSteps} + technology={selectedTechnology} /> ); } @@ -472,7 +473,9 @@ const ContentWrapper = (props) => { module === currentModule).map(({ module }) => module)} + selectedKeys={menuLinks + .filter(({ module }) => module === currentModule) + .map(({ module }) => module)} mode='inline' items={menuItems} /> diff --git a/src/components/GEM2SLoadingScreen.jsx b/src/components/GEM2SLoadingScreen.jsx index 89439c3b89..3045c16420 100644 --- a/src/components/GEM2SLoadingScreen.jsx +++ b/src/components/GEM2SLoadingScreen.jsx @@ -20,6 +20,17 @@ const pipelineStepsInfoByType = { 'Uploading completed data', ], + // Spatial (visium_hd) gem2s skips the EmptyDrops + DoubletScores steps, so it + // runs fewer steps than the single-cell pipeline — use a matching list so the + // progress bar reflects the real step count (otherwise it caps below 100%). + gem2sSpatial: [ + 'Downloading sample files', + 'Preprocessing samples', + 'Converting samples', + 'Preparing analysis', + 'Uploading completed data', + ], + obj2s: [ 'Downloading project data', 'Processing object', @@ -27,6 +38,9 @@ const pipelineStepsInfoByType = { ], }; +// technologies whose gem2s pipeline uses the spatial (shorter) step list +const SPATIAL_TECHNOLOGIES = ['visium_hd', 'xenium']; + const GEM2SLoadingScreen = (props) => { const { pipelineStatus, @@ -35,9 +49,13 @@ const GEM2SLoadingScreen = (props) => { experimentName, pipelineType, pipelineErrorMessage, + technology, } = props; - const pipelineStepsInfo = pipelineStepsInfoByType[pipelineType]; + const isSpatialGem2s = pipelineType === 'gem2s' && SPATIAL_TECHNOLOGIES.includes(technology); + const pipelineStepsInfo = isSpatialGem2s + ? pipelineStepsInfoByType.gem2sSpatial + : pipelineStepsInfoByType[pipelineType]; const dispatch = useDispatch(); const dataManagementPath = '/data-management'; @@ -117,8 +135,8 @@ const GEM2SLoadingScreen = (props) => {
- - {(pipelineStepsInfo[completedSteps.length])} + + {(pipelineStepsInfo[Math.min(completedSteps.length, pipelineStepsInfo.length - 1)])}
@@ -134,7 +152,8 @@ const GEM2SLoadingScreen = (props) => { {pipelineStatus === 'subsetting' && (
- Your new project containing only the selected cell sets will be available in the Data Management module + Your new project containing only the selected cell sets will be available in + the Data Management module
)}
@@ -173,6 +192,7 @@ GEM2SLoadingScreen.propTypes = { experimentId: PropTypes.string, experimentName: PropTypes.string, pipelineErrorMessage: PropTypes.string, + technology: PropTypes.string, }; GEM2SLoadingScreen.defaultProps = { @@ -180,6 +200,7 @@ GEM2SLoadingScreen.defaultProps = { experimentId: null, experimentName: null, pipelineErrorMessage: null, + technology: null, }; export default GEM2SLoadingScreen; diff --git a/src/components/data-exploration/DynamicVitessceWrappers.jsx b/src/components/data-exploration/DynamicVitessceWrappers.jsx index 95438214ad..b1f279fb88 100644 --- a/src/components/data-exploration/DynamicVitessceWrappers.jsx +++ b/src/components/data-exploration/DynamicVitessceWrappers.jsx @@ -2,7 +2,5 @@ // a wrapper component to import the Scatterplot and Heatmap components. // See https://github.com/webpack/webpack/issues/13865 import { Heatmap } from '@vitessce/heatmap'; -import { Scatterplot } from '@vitessce/scatterplot'; -import { Spatial } from '@vitessce/spatial'; -export { Heatmap, Scatterplot, Spatial }; +export { Heatmap }; diff --git a/src/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.jsx b/src/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.jsx index 63c0ebf707..7cf6bfee69 100644 --- a/src/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.jsx +++ b/src/components/data-exploration/cell-sets-tool/SubsetCellSetsOperation.jsx @@ -7,7 +7,7 @@ import { Tooltip, Button } from 'antd'; import { PieChartOutlined } from '@ant-design/icons'; import SubsetCellSetsModal from 'components/data-exploration/cell-sets-tool/SubsetCellSetsModal'; -import { obj2sTechs } from 'utils/constants'; +import { obj2sTechs, spatialTechs } from 'utils/constants'; const SubsetCellSetsOperation = (props) => { const { onCreate } = props; @@ -18,12 +18,23 @@ const SubsetCellSetsOperation = (props) => { const [showSubsetCellSets, setShowSubsetCellSets] = useState(false); + // Subsetting is unavailable for spatial technologies (e.g. visium_hd, xenium) + // until the spatial subsetting semantics (segmentations/coords/image) are + // defined. Obj2s techs remain disabled as before. + const isSpatial = spatialTechs.includes(experimentType); + const subsetDisabled = obj2sTechs.includes(experimentType) || isSpatial; + return ( <> - + ))} @@ -186,7 +205,9 @@ const PlotLayout = ({ {renderPlot()} - + {/* fixed-width mini-plot column pinned to the right, so the preview tiles + don't shift left→right as the (initially empty) main plot/image fills in */} + {renderMiniPlots()} diff --git a/src/components/data-processing/SpatialMitoOutlier/SpatialMitoOutlier.jsx b/src/components/data-processing/SpatialMitoOutlier/SpatialMitoOutlier.jsx new file mode 100644 index 0000000000..d9da926165 --- /dev/null +++ b/src/components/data-processing/SpatialMitoOutlier/SpatialMitoOutlier.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import SpatialOutlierFilter from 'components/data-processing/SpatialOutlierFilter/SpatialOutlierFilter'; + +// Visium HD spatial mitochondrial-content local-outlier filter: removes cells +// with unusually high mitochondrial content relative to their spatial +// neighborhood (not log scale, upper tail). +const SpatialMitoOutlier = ({ + experimentId, sampleId, sampleIds, onConfigChange, stepDisabled, stepHadErrors, +}) => ( + +); + +SpatialMitoOutlier.propTypes = { + experimentId: PropTypes.string.isRequired, + sampleId: PropTypes.string.isRequired, + sampleIds: PropTypes.array.isRequired, + onConfigChange: PropTypes.func.isRequired, + stepDisabled: PropTypes.bool, + stepHadErrors: PropTypes.bool.isRequired, +}; + +SpatialMitoOutlier.defaultProps = { + stepDisabled: false, +}; + +export default SpatialMitoOutlier; diff --git a/src/components/data-processing/SpatialNumGenesOutlier/SpatialNumGenesOutlier.jsx b/src/components/data-processing/SpatialNumGenesOutlier/SpatialNumGenesOutlier.jsx new file mode 100644 index 0000000000..253f31ef7e --- /dev/null +++ b/src/components/data-processing/SpatialNumGenesOutlier/SpatialNumGenesOutlier.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import SpatialOutlierFilter from 'components/data-processing/SpatialOutlierFilter/SpatialOutlierFilter'; + +// Visium HD spatial number-of-genes local-outlier filter: removes cells with +// unusually few detected genes relative to their spatial neighborhood +// (log scale, lower tail). +const SpatialNumGenesOutlier = ({ + experimentId, sampleId, sampleIds, onConfigChange, stepDisabled, stepHadErrors, +}) => ( + +); + +SpatialNumGenesOutlier.propTypes = { + experimentId: PropTypes.string.isRequired, + sampleId: PropTypes.string.isRequired, + sampleIds: PropTypes.array.isRequired, + onConfigChange: PropTypes.func.isRequired, + stepDisabled: PropTypes.bool, + stepHadErrors: PropTypes.bool.isRequired, +}; + +SpatialNumGenesOutlier.defaultProps = { + stepDisabled: false, +}; + +export default SpatialNumGenesOutlier; diff --git a/src/components/data-processing/SpatialOutlierFilter/CalculationConfig.jsx b/src/components/data-processing/SpatialOutlierFilter/CalculationConfig.jsx new file mode 100644 index 0000000000..6f47516e8f --- /dev/null +++ b/src/components/data-processing/SpatialOutlierFilter/CalculationConfig.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from 'antd'; + +import SliderWithInput from '../../SliderWithInput'; + +// Shared settings for the spatial local-outlier filters: a single z-score cutoff +// (plus a histogram bin step). Direction is fixed per filter, not user-editable. +const SpatialOutlierCalculationConfig = (props) => { + const { config, disabled, updateSettings } = props; + + return ( + <> + + updateSettings({ cutoff: newValue })} + disabled={disabled} + /> + + + updateSettings({ binStep: newValue })} + disabled={disabled} + /> + + + ); +}; + +SpatialOutlierCalculationConfig.defaultProps = { + updateSettings: () => {}, + config: {}, + disabled: false, +}; + +SpatialOutlierCalculationConfig.propTypes = { + updateSettings: PropTypes.func, + config: PropTypes.object, + disabled: PropTypes.bool, +}; + +export default SpatialOutlierCalculationConfig; diff --git a/src/components/data-processing/SpatialOutlierFilter/SpatialOutlierFilter.jsx b/src/components/data-processing/SpatialOutlierFilter/SpatialOutlierFilter.jsx new file mode 100644 index 0000000000..5771737bec --- /dev/null +++ b/src/components/data-processing/SpatialOutlierFilter/SpatialOutlierFilter.jsx @@ -0,0 +1,171 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; + +import { imagelessTechs } from 'utils/constants'; +import { generateDataProcessingPlotUuid } from 'utils/generateCustomPlotUuid'; + +import PlotLayout from 'components/data-processing/PlotLayout'; +import BasicFilterPlot from 'components/plots/BasicFilterPlot'; +import SpatialOutlierFilterPlot from 'components/plots/SpatialOutlierFilterPlot'; +import generateZscoreHistogram from 'utils/plotSpecs/generateZscoreHistogram'; +import CalculationConfig from './CalculationConfig'; + +/** + * Shared implementation for the three Visium HD spatial local-outlier filters. + * Each filter differs only in its config key, outlier direction, plot types and + * titles — passed in by the thin wrapper components. + */ +const SpatialOutlierFilter = (props) => { + const { + experimentId, sampleId, sampleIds, onConfigChange, stepDisabled, stepHadErrors, + filterName, direction, mainPlotType, outlierPlotType, histogramPlotType, + mainPlotTitle, histogramTitle, + } = props; + + // imageless techs (e.g. Xenium) render no tissue image, so the showImage + // toggle would control nothing — drop the panel for those technologies + const technology = useSelector((state) => state.samples?.[sampleId]?.type); + const isImageless = imagelessTechs.includes(technology); + + const filterTableUuid = generateDataProcessingPlotUuid(sampleId, filterName, 2); + + // The metric and outlier views share the same per-cell plotData (index 0), but + // have independent plot CONFIG (separate plotUuid/plotType) so styling one slide + // — e.g. inverting the background — doesn't affect the other. The outlier view's + // config (index 3) lives under its own uuid but reads the index-0 plotData via + // `dataPlotUuid`. + const slidePlotUuid = generateDataProcessingPlotUuid(sampleId, filterName, 0); + const outlierPlotUuid = generateDataProcessingPlotUuid(sampleId, filterName, 3); + + const plots = { + metricPlot: { + title: mainPlotTitle, + plotUuid: slidePlotUuid, + plotType: mainPlotType, + plot: (config, plotData, actions, onZoomChange) => ( + + ), + }, + outlierPlot: { + title: 'Outliers', + plotUuid: outlierPlotUuid, + dataPlotUuid: slidePlotUuid, + plotType: outlierPlotType, + plot: (config, plotData, actions, onZoomChange) => ( + + ), + }, + zscoreHistogram: { + title: histogramTitle, + plotUuid: generateDataProcessingPlotUuid(sampleId, filterName, 1), + plotType: histogramPlotType, + plot: (config, plotData, actions) => ( + + ), + }, + }; + + const plotStylingControlsConfig = [ + ...(isImageless ? [] : [{ + panelTitle: 'Tissue image', + controls: ['showImage'], + }]), + { + panelTitle: 'Main schema', + controls: ['dimensions'], + children: [ + { panelTitle: 'Title', controls: ['title'] }, + { panelTitle: 'Font', controls: ['font'] }, + ], + }, + { + panelTitle: 'Axes options', + controls: ['axes'], + }, + { + panelTitle: 'Colours', + controls: ['colourScheme', 'colourInversion', 'colourReverse'], + }, + { + panelTitle: 'Segmentations', + controls: [{ name: 'markers', props: { spatial: true } }], + }, + { + panelTitle: 'Legend', + controls: [{ + name: 'legend', + props: { + option: { positions: 'top-bottom' }, + defaultTitle: mainPlotTitle, + }, + }], + }, + ]; + + const renderCalculationConfig = () => ; + + return ( + + ); +}; + +SpatialOutlierFilter.propTypes = { + experimentId: PropTypes.string.isRequired, + sampleId: PropTypes.string.isRequired, + sampleIds: PropTypes.array.isRequired, + onConfigChange: PropTypes.func.isRequired, + stepDisabled: PropTypes.bool, + stepHadErrors: PropTypes.bool.isRequired, + filterName: PropTypes.string.isRequired, + direction: PropTypes.oneOf(['lower', 'upper']).isRequired, + mainPlotType: PropTypes.string.isRequired, + outlierPlotType: PropTypes.string.isRequired, + histogramPlotType: PropTypes.string.isRequired, + mainPlotTitle: PropTypes.string.isRequired, + histogramTitle: PropTypes.string.isRequired, +}; + +SpatialOutlierFilter.defaultProps = { + stepDisabled: false, +}; + +export default SpatialOutlierFilter; diff --git a/src/components/data-processing/SpatialUmiOutlier/SpatialUmiOutlier.jsx b/src/components/data-processing/SpatialUmiOutlier/SpatialUmiOutlier.jsx new file mode 100644 index 0000000000..12b988377d --- /dev/null +++ b/src/components/data-processing/SpatialUmiOutlier/SpatialUmiOutlier.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import SpatialOutlierFilter from 'components/data-processing/SpatialOutlierFilter/SpatialOutlierFilter'; + +// Visium HD spatial UMI local-outlier filter: removes cells with unusually low +// total UMI count relative to their spatial neighborhood (log scale, lower tail). +const SpatialUmiOutlier = ({ + experimentId, sampleId, sampleIds, onConfigChange, stepDisabled, stepHadErrors, +}) => ( + +); + +SpatialUmiOutlier.propTypes = { + experimentId: PropTypes.string.isRequired, + sampleId: PropTypes.string.isRequired, + sampleIds: PropTypes.array.isRequired, + onConfigChange: PropTypes.func.isRequired, + stepDisabled: PropTypes.bool, + stepHadErrors: PropTypes.bool.isRequired, +}; + +SpatialUmiOutlier.defaultProps = { + stepDisabled: false, +}; + +export default SpatialUmiOutlier; diff --git a/src/components/plots/BasicFilterPlot.jsx b/src/components/plots/BasicFilterPlot.jsx index 2e8d073e22..1e7eadec1b 100644 --- a/src/components/plots/BasicFilterPlot.jsx +++ b/src/components/plots/BasicFilterPlot.jsx @@ -16,7 +16,7 @@ const BasicFilterPlot = (props) => { } return (
- +
); }; diff --git a/src/components/plots/CategoricalEmbeddingPlot.jsx b/src/components/plots/CategoricalEmbeddingPlot.jsx index 1d74090f17..4aa8136251 100644 --- a/src/components/plots/CategoricalEmbeddingPlot.jsx +++ b/src/components/plots/CategoricalEmbeddingPlot.jsx @@ -110,6 +110,7 @@ const CategoricalEmbeddingPlot = (props) => { spec={plotSpec} renderer='webgl' actions={actions} + scaleFactor={3} /> ); diff --git a/src/components/plots/ContinuousEmbeddingPlot.jsx b/src/components/plots/ContinuousEmbeddingPlot.jsx index 54b24514b7..5b1e8f3e23 100644 --- a/src/components/plots/ContinuousEmbeddingPlot.jsx +++ b/src/components/plots/ContinuousEmbeddingPlot.jsx @@ -117,7 +117,7 @@ const ContinuousEmbeddingPlot = (props) => { return (
- +
); }; diff --git a/src/components/plots/DotPlot.jsx b/src/components/plots/DotPlot.jsx index e8b4e2f7f3..c4c60ae409 100644 --- a/src/components/plots/DotPlot.jsx +++ b/src/components/plots/DotPlot.jsx @@ -40,7 +40,7 @@ const DotPlot = (props) => { // PlotData has to be cloned for this plot because // Immer freezes plotData meanwhile the plot needs to modify it to work - return ; + return ; }; return render(); diff --git a/src/components/plots/ElbowPlot.jsx b/src/components/plots/ElbowPlot.jsx index e33b160930..8014510e5b 100644 --- a/src/components/plots/ElbowPlot.jsx +++ b/src/components/plots/ElbowPlot.jsx @@ -25,7 +25,7 @@ const ElbowPlot = (props) => { return (
- +
); }; diff --git a/src/components/plots/FrequencyPlot.jsx b/src/components/plots/FrequencyPlot.jsx index 7c6cc6fb07..3aa3ed0e80 100644 --- a/src/components/plots/FrequencyPlot.jsx +++ b/src/components/plots/FrequencyPlot.jsx @@ -61,7 +61,7 @@ const FrequencyPlot = (props) => { return ; } - return ; + return ; }; FrequencyPlot.propTypes = { diff --git a/src/components/plots/MiniPlot.jsx b/src/components/plots/MiniPlot.jsx index 1ba17b55d2..6d581caee4 100644 --- a/src/components/plots/MiniPlot.jsx +++ b/src/components/plots/MiniPlot.jsx @@ -39,12 +39,16 @@ const getMiniaturizedConfig = (config) => { const MiniPlot = (props) => { const { - plotUuid, plotFn, actions, + plotUuid, dataPlotUuid, plotFn, actions, } = props; - const { config, plotData } = useSelector( + const { config } = useSelector( (state) => state.componentConfig[plotUuid] || {}, ); + // plotData may live under a different uuid than the config (see PlotLayout) + const { plotData } = useSelector( + (state) => state.componentConfig[dataPlotUuid || plotUuid] || {}, + ); const renderPlot = () => { if (!config) { @@ -66,8 +70,13 @@ const MiniPlot = (props) => { MiniPlot.propTypes = { experimentId: PropTypes.string.isRequired, plotUuid: PropTypes.string.isRequired, + dataPlotUuid: PropTypes.string, plotFn: PropTypes.func.isRequired, actions: PropTypes.bool.isRequired, }; +MiniPlot.defaultProps = { + dataPlotUuid: null, +}; + export default React.memo(MiniPlot); diff --git a/src/components/plots/PlotContainer.jsx b/src/components/plots/PlotContainer.jsx index 4af95c4b56..a962d518b8 100644 --- a/src/components/plots/PlotContainer.jsx +++ b/src/components/plots/PlotContainer.jsx @@ -1,10 +1,12 @@ /* eslint-disable react/require-default-props */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { + useState, useEffect, useCallback, useMemo, +} from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { - Button, Card, Space, Tooltip, + Button, Card, Space, Tooltip, Skeleton, } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; @@ -17,7 +19,7 @@ import { getCellSets } from 'redux/selectors'; import _ from 'lodash'; import PlotStyling from 'components/plots/styling/PlotStyling'; import MultiTileContainer from 'components/MultiTileContainer'; -import { getEmbeddingInitialConfig, isEmbeddingPlotType } from 'utils/plotConfig/getEmbeddingInitialConfig'; +import { getEmbeddingInitialConfig, isEmbeddingPlotType, getTotalCellCount } from 'utils/plotConfig/getEmbeddingInitialConfig'; const PLOT = 'Plot'; const CONTROLS = 'Controls'; @@ -43,7 +45,8 @@ const PlotContainer = (props) => { const { config } = useSelector((state) => state.componentConfig[plotUuid] || {}); const cellSets = useSelector(getCellSets()); const debounceSave = useCallback( - _.debounce(() => dispatch(savePlotConfig(experimentId, plotUuid)), saveDebounceTime), [plotUuid], + _.debounce(() => dispatch(savePlotConfig(experimentId, plotUuid)), saveDebounceTime), + [plotUuid], ); const defaultOnUpdate = (obj) => { dispatch(updatePlotConfig(plotUuid, obj)); @@ -108,7 +111,8 @@ const PlotContainer = (props) => { // Auto-apply large-dataset defaults for embedding plots when config first loads with cellSets useEffect(() => { - if (!isEmbeddingPlotType(plotType) || !config || !cellSets?.properties || !cellSets?.hierarchy) return; + if (!isEmbeddingPlotType(plotType) || !config + || !cellSets?.properties || !cellSets?.hierarchy) return; if (config.defaultValues?.largeDatasetDefaults) return; // Already applied const cellCount = cellSets.hierarchy?.find((node) => node.key === 'sample') @@ -126,6 +130,16 @@ const PlotContainer = (props) => { } }, [config, cellSets, plotType, plotUuid, experimentId]); + // True while a large-dataset embedding plot is still showing its standard marker — + // i.e. the large-dataset defaults haven't been applied yet. Used to hold a loader + // so the plot never flashes the standard point size/outline then re-adjusts. + const largeDatasetPending = useMemo(() => { + if (!isEmbeddingPlotType(plotType) || !config + || !cellSets?.properties || !cellSets?.hierarchy) return false; + if (config.defaultValues?.largeDatasetDefaults) return false; // already applied + return getTotalCellCount(cellSets) > 100000; + }, [plotType, config, cellSets]); + const onClickReset = () => { // For embedding plots with large datasets, use optimized defaults if (isEmbeddingPlotType(plotType)) { @@ -139,14 +153,29 @@ const PlotContainer = (props) => { } return acc; }, initialConfig); - + dispatch(updatePlotConfig(plotUuid, resetConfig)); debounceSave(); onPlotReset(); return; } } - + + // Any plot may mark fields to keep across a reset (e.g. the selected sample / + // genes); restore the defaults but carry those fields over from the live config. + const defaultConfig = initialPlotConfigStates[plotType]; + const keysToPreserve = defaultConfig?.keepValuesOnReset || []; + if (keysToPreserve.length > 0) { + const resetConfig = keysToPreserve.reduce((acc, key) => { + if (config?.[key] !== undefined) acc[key] = config[key]; + return acc; + }, _.cloneDeep(defaultConfig)); + dispatch(updatePlotConfig(plotUuid, resetConfig)); + debounceSave(); + onPlotReset(); + return; + } + dispatch(resetPlotConfig(experimentId, plotUuid, plotType)); onPlotReset(); }; @@ -173,7 +202,7 @@ const PlotContainer = (props) => { ); - const renderDefaultControlPanel = (height) => ( + const renderDefaultControlPanel = () => ( { const TILE_MAP = { [PLOT]: { toolbarControls: renderPlotToolbarControls(), - component: () => children, + // hold a loader until large-dataset marker defaults are applied (no flash) + component: () => (largeDatasetPending + ?
+ : children), style: { display: 'flex', backgroundColor: 'white', @@ -197,8 +229,8 @@ const PlotContainer = (props) => { }, [CONTROLS]: { toolbarControls: [], - component: (width, height) => ( - customControlPanel ?? renderDefaultControlPanel(height) + component: () => ( + customControlPanel ?? renderDefaultControlPanel() ), style: { margin: '-10px' }, }, diff --git a/src/components/plots/PlotsTablesContainer.jsx b/src/components/plots/PlotsTablesContainer.jsx index 9fcd100611..cb2485d903 100644 --- a/src/components/plots/PlotsTablesContainer.jsx +++ b/src/components/plots/PlotsTablesContainer.jsx @@ -7,6 +7,10 @@ import Link from 'next/link'; import { plotNames, layout, spatialPlotNames } from 'utils/constants'; +// Plot types only meaningful for molecule-capable spatial techs (e.g. Xenium): +// they are hidden for every other technology even when the experiment is spatial. +const MOLECULE_PLOT_NAMES = [plotNames.SPATIAL_MOLECULES]; + const CARD_STYLE = { marginBottom: '1em' }; const CardItem = (({ item, experimentId, @@ -84,6 +88,12 @@ const plots = [ key: 'spatial-feature-key', link: 'spatial-feature', }, + { + name: plotNames.SPATIAL_MOLECULES, + image: '/static/media/spatialMolecules.png', + key: 'spatial-molecules-key', + link: 'spatial-molecules', + }, { name: plotNames.MARKER_HEATMAP, image: '/static/media/marker_heatmap.png', @@ -131,9 +141,18 @@ const plots = [ const PlotsTablesContainer = (props) => { const { - width, height, experimentId, isSpatial, + width, height, experimentId, isSpatial, hasMolecules, } = props; + // A plot card is shown unless it is gated to a capability the experiment lacks: + // spatial plots need a spatial tech; molecule plots additionally need a + // molecule-capable tech (e.g. Xenium). + const isPlotVisible = (name) => { + if (MOLECULE_PLOT_NAMES.includes(name)) return hasMolecules; + if (spatialPlotNames.includes(name)) return isSpatial; + return true; + }; + return (
{ {section.plots.map((item) => ( - !spatialPlotNames.includes(item.name) || isSpatial + isPlotVisible(item.name) ? ( { PlotsTablesContainer.propTypes = { experimentId: PropTypes.string.isRequired, isSpatial: PropTypes.bool.isRequired, + hasMolecules: PropTypes.bool, width: PropTypes.number, height: PropTypes.number, }; PlotsTablesContainer.defaultProps = { + hasMolecules: false, width: null, height: null, }; diff --git a/src/components/plots/SpatialCategoricalPlot.jsx b/src/components/plots/SpatialCategoricalPlot.jsx index 872795c053..61d2f99a7c 100644 --- a/src/components/plots/SpatialCategoricalPlot.jsx +++ b/src/components/plots/SpatialCategoricalPlot.jsx @@ -1,16 +1,22 @@ -import React, { useState, useEffect } from 'react'; +import React, { + useState, useEffect, useMemo, useRef, useCallback, +} from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { Vega } from 'react-vega'; import 'vega-webgl-renderer'; +import _ from 'lodash'; import { loadCellSets } from 'redux/actions/cellSets'; import { loadEmbedding } from 'redux/actions/embedding'; import { loadProcessingSettings } from 'redux/actions/experimentSettings'; import { getCellSets } from 'redux/selectors'; -import { generateSpec, generateData } from 'utils/plotSpecs/generateSpatialCategoricalSpec'; +import { generateSpec, generateData, filterCells } from 'utils/plotSpecs/generateSpatialCategoricalSpec'; import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; -import getImageUrls from './getImageUrls'; +import { imagelessTechs } from 'utils/constants'; +import { parseHexColor } from './loadSegmentationOverlay'; +import useSpatialStream from './useSpatialStream'; +import usePreventWheelScroll from './usePreventWheelScroll'; import PlatformError from '../PlatformError'; import Loader from '../Loader'; @@ -18,12 +24,12 @@ const EMBEDDING_TYPE = 'images'; const SpatialCategoricalPlot = (props) => { const { - experimentId, - config, - actions, + experimentId, config, actions, onZoomChange, onSampleDefault, } = props; const dispatch = useDispatch(); + const onSampleDefaultRef = useRef(onSampleDefault); + onSampleDefaultRef.current = onSampleDefault; const { data: embeddingData, @@ -38,22 +44,38 @@ const SpatialCategoricalPlot = (props) => { const cellSets = useSelector(getCellSets()); const sampleIdsForFileUrls = useSelector((state) => state.experimentSettings.info.sampleIds); - const isObj2s = useSelector((state) => state.backendStatus[experimentId].status.obj2s.status !== null); + const obj2sStatusRaw = useSelector( + (state) => state.backendStatus[experimentId]?.status?.obj2s?.status, + ); + const isObj2s = !_.isNil(obj2sStatusRaw) && obj2sStatusRaw !== 'NOT_CREATED'; + const technology = useSelector((state) => state.samples?.[sampleIdsForFileUrls?.[0]]?.type); + const isImageless = imagelessTechs.includes(technology); const [plotSpec, setPlotSpec] = useState({}); - const [imageUrls, setImageUrls] = useState({}); const [omeZarrUrls, setOmeZarrUrls] = useState(null); + const [segmentationZarrUrls, setSegmentationZarrUrls] = useState(null); const [selectedSample, setSelectedSample] = useState(); + // keep the latest onZoomChange in a ref so the debounced persister always calls + // the current callback without re-creating the debounce + const onZoomChangeRef = useRef(onZoomChange); + onZoomChangeRef.current = onZoomChange; + + // ── Fetch image URLs ──────────────────────────────────────────────────────── useEffect(() => { + // imageless techs (e.g. Xenium) have no tissue image; still register the + // sample ids (with null urls) so the selected-sample default and the stream + // initialise — image dimensions come from the segmentation pyramid instead. + if (isImageless) { + setOmeZarrUrls(sampleIdsForFileUrls.map((sampleId) => ({ url: null, sampleId }))); + return; + } (async () => { try { const results = (await Promise.all( sampleIdsForFileUrls.map((sampleId) => getSampleFileUrls(experimentId, sampleId, 'ome_zarr_zip')), )).flat(); - // For obj2s, file IDs correspond to sample IDs - // whereas there is a single dummy sample ID in state const signedUrls = results.map(({ url, fileId }, i) => ({ url, sampleId: isObj2s ? fileId : sampleIdsForFileUrls[i], @@ -61,66 +83,205 @@ const SpatialCategoricalPlot = (props) => { setOmeZarrUrls(signedUrls); } catch (error) { - console.error('Error fetching URLs:', error); + console.error('Error fetching image URLs:', error); } - })(); // Immediately invoked function expression (IIFE) - }, [sampleIdsForFileUrls, experimentId, isObj2s]); + })(); + }, [sampleIdsForFileUrls, experimentId, isObj2s, isImageless]); + // ── Fetch segmentation URLs (optional) ───────────────────────────────────── useEffect(() => { - if (!omeZarrUrls || !config) return; + (async () => { + try { + const results = (await Promise.all( + sampleIdsForFileUrls.map((sampleId) => getSampleFileUrls(experimentId, sampleId, 'segmentations_ome_zarr_zip')), + )).flat(); - // default to first sample - const sampleId = config.selectedSample || omeZarrUrls[0].sampleId; - setSelectedSample(sampleId); - }, [config, omeZarrUrls]); + const signedUrls = results.map(({ url, fileId }, i) => ({ + url, + sampleId: isObj2s ? fileId : sampleIdsForFileUrls[i], + })); - useEffect(() => { - if (!omeZarrUrls || !selectedSample || imageUrls[selectedSample]) return; + setSegmentationZarrUrls(signedUrls); + } catch (_e) { + console.info('[SpatialCategoricalPlot] segmentations_ome_zarr_zip not available — centroid fallback active'); + setSegmentationZarrUrls([]); // empty = confirmed unavailable + } + })(); + }, [sampleIdsForFileUrls, experimentId, isObj2s]); - const { url: selectedUrl } = omeZarrUrls.find(({ sampleId }) => sampleId === selectedSample); + // ── Default selected sample ───────────────────────────────────────────────── + // When the config has no sample yet, default to the first one AND persist it back + // to the config, so the "Selected sample" dropdown reflects what's actually shown + // (its own fallback uses a different ordering and would otherwise mismatch). + useEffect(() => { + if (!omeZarrUrls || !config) return; + if (config.selectedSample) { + setSelectedSample(config.selectedSample); + } else { + const def = omeZarrUrls[0]?.sampleId; + if (!def) return; + setSelectedSample(def); + onSampleDefaultRef.current(def); + } + }, [config, omeZarrUrls]); - const loadImageData = async () => { - const sampleImageData = await getImageUrls(selectedUrl); - const newImageUrls = { - ...imageUrls, - [selectedSample]: sampleImageData, - }; + // ── Stable colour-scheme signature ───────────────────────────────────────── + const colorSignature = useMemo(() => { + if (!config?.selectedCellSet || !cellSets.accessible) return ''; + const group = cellSets.hierarchy.find((n) => n.key === config.selectedCellSet); + if (!group) return ''; + return [selectedSample, config.selectedCellSet, group.children + .map(({ key }) => `${key}:${cellSets.properties[key]?.color}`) + .join('|')].join('::'); + }, [selectedSample, config?.selectedCellSet, cellSets.hierarchy, cellSets.properties]); + + // ── Segmentation overlay colour map (per-cell) ────────────────────────────── + const segmentationsAvailableGuess = segmentationZarrUrls?.length > 0; + const cellColorMap = useMemo(() => { + if (!segmentationsAvailableGuess || !cellSets.accessible + || !config?.selectedCellSet || !selectedSample) { + return null; + } + const { filteredCells } = filterCells(cellSets, selectedSample, config.selectedCellSet); + return new Map( + Object.entries(filteredCells).map(([cellIdStr, { color }]) => [ + parseInt(cellIdStr, 10), + parseHexColor(color), + ]), + ); + // colorSignature already captures sample/cell-set/colours + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorSignature, segmentationsAvailableGuess, cellSets.accessible]); + + // ── Viewport streaming (base + detail tiles for tissue & segmentation) ────── + const segEntry = useMemo( + () => segmentationZarrUrls?.find(({ sampleId }) => sampleId === selectedSample), + [segmentationZarrUrls, selectedSample], + ); + const imageEntry = useMemo( + () => omeZarrUrls?.find(({ sampleId }) => sampleId === selectedSample), + [omeZarrUrls, selectedSample], + ); + const segmentationUrl = segmentationZarrUrls === null + ? undefined + : (segEntry?.url ?? null); - setImageUrls(newImageUrls); - }; + const { + imageDims, + segmentationsAvailable, + segProbeDone, + tissueImageData, + segOverlayData, + onViewportChange, + ready, + } = useSpatialStream({ + experimentId, + sampleId: selectedSample, + omeZarrUrl: imageEntry?.url ?? null, + segmentationUrl, + plotWidth: config?.dimensions?.width, + plotHeight: config?.dimensions?.height, + showImage: config?.showImage ?? true, + colorMap: cellColorMap, + colorKey: colorSignature, + opacity: (config?.marker?.opacity ?? 10) / 10, + outline: config?.marker?.outline ?? false, + }); + + // mouse zoom/pan → persist axes range (debounced) AND stream a sharper tile. + const persistZoom = useMemo(() => _.debounce((axesRanges) => { + onZoomChangeRef.current(axesRanges); + }, 250), []); + const onZoomDomUpdate = (name, value) => { + if (config?.miniPlot) return; // mini previews never zoom/stream — always full extent + const [xdom, ydom] = value; + onViewportChange(xdom, ydom); + persistZoom({ + xAxisAuto: false, + xMin: xdom[0], + xMax: xdom[1], + yAxisAuto: false, + yMin: ydom[0], + yMax: ydom[1], + }); + }; - loadImageData(); - }, [omeZarrUrls, selectedSample]); + // Re-apply persisted zoom onto a freshly (re)built view + stream its detail tile. + // Mini previews skip this so they always show the zoomed-out slide, even though + // they share the main plot's axesRanges. + const axesRangesRef = useRef(config?.axesRanges); + axesRangesRef.current = config?.axesRanges; + const isMiniPlot = config?.miniPlot; + const wheelRef = usePreventWheelScroll(!isMiniPlot); + const restoreZoom = useCallback((view) => { + if (isMiniPlot) return; + const ar = axesRangesRef.current; + if (ar && ar.xAxisAuto === false) { + view.signal('initXdom', [ar.xMin, ar.xMax]).signal('initYdom', [ar.yMin, ar.yMax]).runAsync(); + onViewportChange([ar.xMin, ar.xMax], [ar.yMin, ar.yMax]); + } else { + // No persisted zoom (e.g. after Reset Plot): the rebuilt view is at full extent + // (its initXdom/initYdom signals). Tell the streamer so it re-streams the whole + // slide — otherwise it keeps the pre-reset zoomed-in tiles and only that region + // shows. + onViewportChange(view.signal('initXdom'), view.signal('initYdom')); + } + }, [onViewportChange, isMiniPlot]); + // ── Data loading dispatch ─────────────────────────────────────────────────── useEffect(() => { dispatch(loadCellSets(experimentId)); }, [omeZarrUrls]); useEffect(() => { - if (!embeddingSettings) { - dispatch(loadProcessingSettings(experimentId)); - } - + if (!embeddingSettings) dispatch(loadProcessingSettings(experimentId)); if (!embeddingData && embeddingSettings?.method) { dispatch(loadEmbedding(experimentId, EMBEDDING_TYPE)); } }, [embeddingSettings?.method]); + // ── Spec generation ───────────────────────────────────────────────────────── + // Tiles + overlay stream in via the `data` prop — recolour/zoom never rebuild. + const specSignature = useMemo(() => { + if (!config) return ''; + const c = { ...config, axesRanges: undefined }; + if (segmentationsAvailable && c.marker) { + c.marker = { ...c.marker, opacity: undefined, outline: undefined }; + } + return JSON.stringify(c); + }, [config, segmentationsAvailable]); useEffect(() => { - if (config - && cellSets.accessible - && embeddingData?.length - && selectedSample - && imageUrls[selectedSample]) { - const { + if (!ready || !imageDims || !segProbeDone) { + setPlotSpec({}); + return; + } + + if (config && cellSets.accessible && embeddingData?.length && selectedSample) { + const { plotData, cellSetLegendsData } = generateData( + cellSets, selectedSample, config.selectedCellSet, embeddingData, + ); + + setPlotSpec(generateSpec( + config, + EMBEDDING_TYPE, + imageDims, plotData, cellSetLegendsData, - } = generateData(cellSets, selectedSample, config.selectedCellSet, embeddingData); - - setPlotSpec(generateSpec(config, EMBEDDING_TYPE, imageUrls[selectedSample], plotData, cellSetLegendsData)); + segmentationsAvailable, + )); } - }, [config, cellSets, embeddingData, config, imageUrls, selectedSample]); - + }, [ + specSignature, cellSets, embeddingData, selectedSample, + segmentationsAvailable, segProbeDone, imageDims, ready, + ]); + + // tissue + overlay tiles streamed to Vega in place (no view rebuild) via `data` + const vegaData = useMemo(() => ({ + tissueImageData, + segOverlayData, + }), [tissueImageData, segOverlayData]); + + // ── Render ────────────────────────────────────────────────────────────────── const render = () => { if (cellSets.error) { return ( @@ -140,10 +301,12 @@ const SpatialCategoricalPlot = (props) => { ); } - if (!config + if ( + !config || !cellSets.accessible || embeddingLoading - || Object.keys(plotSpec).length === 0) { + || Object.keys(plotSpec).length === 0 + ) { return (
@@ -153,30 +316,36 @@ const SpatialCategoricalPlot = (props) => { return (
- +
+ +
); }; - return ( - <> - {render()} - - ); + return <>{render()}; }; SpatialCategoricalPlot.defaultProps = { config: null, actions: true, + onZoomChange: () => { }, + onSampleDefault: () => { }, }; SpatialCategoricalPlot.propTypes = { experimentId: PropTypes.string.isRequired, config: PropTypes.object, - actions: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.object, - ]), + actions: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), + onZoomChange: PropTypes.func, + onSampleDefault: PropTypes.func, }; export default SpatialCategoricalPlot; diff --git a/src/components/plots/SpatialCategoricalReduxWrapper.jsx b/src/components/plots/SpatialCategoricalReduxWrapper.jsx index 5308bd62f0..2eaf50c8af 100644 --- a/src/components/plots/SpatialCategoricalReduxWrapper.jsx +++ b/src/components/plots/SpatialCategoricalReduxWrapper.jsx @@ -1,6 +1,7 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; +import { updatePlotConfig } from 'redux/actions/componentConfig'; import SpatialCategoricalPlot from './SpatialCategoricalPlot'; // wrapper component used in plots and tables @@ -11,6 +12,7 @@ const SpatialCategoricalReduxWrapper = (props) => { experimentId, actions, plotUuid, } = props; + const dispatch = useDispatch(); const config = useSelector((state) => state.componentConfig[plotUuid]?.config); return ( @@ -18,6 +20,10 @@ const SpatialCategoricalReduxWrapper = (props) => { experimentId={experimentId} config={config} actions={actions} + onZoomChange={(axesRanges) => dispatch(updatePlotConfig(plotUuid, { axesRanges }))} + onSampleDefault={(selectedSample) => dispatch( + updatePlotConfig(plotUuid, { selectedSample }), + )} /> ); }; diff --git a/src/components/plots/SpatialFeaturePlot.jsx b/src/components/plots/SpatialFeaturePlot.jsx index f13ec06025..b14f42bd94 100644 --- a/src/components/plots/SpatialFeaturePlot.jsx +++ b/src/components/plots/SpatialFeaturePlot.jsx @@ -1,21 +1,84 @@ -import React, { useState, useEffect } from 'react'; +import React, { + useState, useEffect, useMemo, useRef, useCallback, +} from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { Vega } from 'react-vega'; +import * as vega from 'vega'; import 'vega-webgl-renderer'; +import _ from 'lodash'; import { loadCellSets } from 'redux/actions/cellSets'; import { loadEmbedding } from 'redux/actions/embedding'; import { loadProcessingSettings } from 'redux/actions/experimentSettings'; import { getCellSets } from 'redux/selectors'; -import { generateSpec, generateData } from 'utils/plotSpecs/generateSpatialFeatureSpec'; +import { generateSpec, generateData, filterCells } from 'utils/plotSpecs/generateSpatialFeatureSpec'; import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; -import getImageUrls from './getImageUrls'; +import { imagelessTechs } from 'utils/constants'; +import useSpatialStream from './useSpatialStream'; +import usePreventWheelScroll from './usePreventWheelScroll'; import PlatformError from '../PlatformError'; import Loader from '../Loader'; const EMBEDDING_TYPE = 'images'; +// Parse a CSS color string returned by vega.scheme interpolators into [r, g, b]. +const parseCssColor = (cssColor) => { + if (!cssColor) return [128, 128, 128]; + const rgbMatch = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(cssColor); + if (rgbMatch) { + return [parseInt(rgbMatch[1], 10), parseInt(rgbMatch[2], 10), parseInt(rgbMatch[3], 10)]; + } + const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cssColor); + if (hexMatch) { + return [parseInt(hexMatch[1], 16), parseInt(hexMatch[2], 16), parseInt(hexMatch[3], 16)]; + } + return [128, 128, 128]; +}; + +// Build Map from expression values, using the same colour +// scale logic as the Vega spec so the overlay matches the legend exactly. +const buildFeatureCellColorMap = (filteredCellIds, plotDataArr, embeddingData, config) => { + const cells = []; + embeddingData.forEach((coords, cellId) => { + if (!filteredCellIds.has(cellId) || coords === undefined) return; + const value = plotDataArr[cellId]; + if (value === undefined || value === null) return; + cells.push({ cellId, value }); + }); + + if (cells.length === 0) return new Map(); + + // Use reduce instead of Math.min/max(...array) — spreading large arrays + // into variadic functions exhausts the call stack. + let minVal = Infinity; + let maxVal = -Infinity; + cells.forEach(({ value }) => { + if (value < minVal) minVal = value; + if (value > maxVal) maxVal = value; + }); + const range = maxVal - minVal || 1; + + const schemeName = config.colour.gradient === 'default' + ? (config.colour.toggleInvert === '#FFFFFF' ? 'purplered' : 'darkgreen') + : config.colour.gradient; + + // spectral defaults to reversed; reverseCbar flips that (XOR) — see generateSpatialFeatureSpec + const shouldReverse = (config.colour.gradient === 'spectral') !== Boolean(config.colour.reverseCbar); + const interpolator = vega.scheme(schemeName); + + if (typeof interpolator !== 'function') return new Map(); + + const map = new Map(); + cells.forEach(({ cellId, value }) => { + let t = (value - minVal) / range; + if (shouldReverse) t = 1 - t; + map.set(cellId, parseCssColor(interpolator(t))); + }); + + return map; +}; + const SpatialFeaturePlot = (props) => { const { experimentId, @@ -26,9 +89,13 @@ const SpatialFeaturePlot = (props) => { loading, error, reloadPlotData, + onZoomChange, + onSampleDefault, } = props; const dispatch = useDispatch(); + const onSampleDefaultRef = useRef(onSampleDefault); + onSampleDefaultRef.current = onSampleDefault; const { data: embeddingData, @@ -43,99 +110,266 @@ const SpatialFeaturePlot = (props) => { const cellSets = useSelector(getCellSets()); const sampleIdsForFileUrls = useSelector((state) => state.experimentSettings.info.sampleIds); - const isObj2s = useSelector((state) => state.backendStatus[experimentId].status.obj2s.status !== null); + const obj2sStatusRaw = useSelector( + (state) => state.backendStatus[experimentId]?.status?.obj2s?.status, + ); + const isObj2s = !_.isNil(obj2sStatusRaw) && obj2sStatusRaw !== 'NOT_CREATED'; + const technology = useSelector((state) => state.samples?.[sampleIdsForFileUrls?.[0]]?.type); + const isImageless = imagelessTechs.includes(technology); const [plotSpec, setPlotSpec] = useState({}); - const [imageUrls, setImageUrls] = useState({}); const [omeZarrUrls, setOmeZarrUrls] = useState(null); + const [segmentationZarrUrls, setSegmentationZarrUrls] = useState(null); const [selectedSample, setSelectedSample] = useState(); + // keep the latest onZoomChange in a ref so the debounced persister always calls + // the current callback without re-creating the debounce + const onZoomChangeRef = useRef(onZoomChange); + onZoomChangeRef.current = onZoomChange; + + // ── Fetch image URLs ──────────────────────────────────────────────────────── useEffect(() => { + // imageless techs (e.g. Xenium) have no tissue image; still register the + // sample ids (with null urls) so the selected-sample default and the stream + // initialise — image dimensions come from the segmentation pyramid instead. + if (isImageless) { + setOmeZarrUrls(sampleIdsForFileUrls.map((sampleId) => ({ url: null, sampleId }))); + return; + } (async () => { try { const results = (await Promise.all( sampleIdsForFileUrls.map((sampleId) => getSampleFileUrls(experimentId, sampleId, 'ome_zarr_zip')), )).flat(); - // For obj2s, file IDs correspond to sample IDs - // whereas there is a single dummy sample ID in state const signedUrls = results.map(({ url, fileId }, i) => ({ url, sampleId: isObj2s ? fileId : sampleIdsForFileUrls[i], })); setOmeZarrUrls(signedUrls); - } catch (error) { - console.error('Error fetching URLs:', error); + } catch (e) { + console.error('Error fetching image URLs:', e); } - })(); // Immediately invoked function expression (IIFE) - }, [sampleIdsForFileUrls, experimentId, isObj2s]); + })(); + }, [sampleIdsForFileUrls, experimentId, isObj2s, isImageless]); + // ── Fetch segmentation URLs (optional) ───────────────────────────────────── useEffect(() => { - if (!omeZarrUrls || !config) return; + (async () => { + try { + const results = (await Promise.all( + sampleIdsForFileUrls.map((sampleId) => getSampleFileUrls(experimentId, sampleId, 'segmentations_ome_zarr_zip')), + )).flat(); - // default to first sample - const sampleId = config.selectedSample || omeZarrUrls[0].sampleId; - setSelectedSample(sampleId); - }, [config, omeZarrUrls]); + const signedUrls = results.map(({ url, fileId }, i) => ({ + url, + sampleId: isObj2s ? fileId : sampleIdsForFileUrls[i], + })); - useEffect(() => { - if (!omeZarrUrls || !selectedSample || imageUrls[selectedSample]) return; + setSegmentationZarrUrls(signedUrls); + } catch (_e) { + console.info('[SpatialFeaturePlot] segmentations_ome_zarr_zip not available — centroid fallback active'); + setSegmentationZarrUrls([]); // empty = confirmed unavailable + } + })(); + }, [sampleIdsForFileUrls, experimentId, isObj2s]); - const { url: selectedUrl } = omeZarrUrls.find(({ sampleId }) => sampleId === selectedSample); + // ── Default selected sample ───────────────────────────────────────────────── + // When the config has no sample yet, default to the first one AND persist it back + // to the config, so the "Selected sample" dropdown reflects what's actually shown + // (its own fallback uses a different ordering and would otherwise mismatch). + useEffect(() => { + if (!omeZarrUrls || !config) return; + if (config.selectedSample) { + setSelectedSample(config.selectedSample); + } else { + const def = omeZarrUrls[0]?.sampleId; + if (!def) return; + setSelectedSample(def); + onSampleDefaultRef.current(def); + } + }, [config, omeZarrUrls]); - const loadImageData = async () => { - const sampleImageData = await getImageUrls(selectedUrl); - const newImageUrls = { - ...imageUrls, - [selectedSample]: sampleImageData, - }; + // ── Colour signature ──────────────────────────────────────────────────────── + // Changes when the gene, truncation, colour scheme, or sample changes. + const colorSignature = useMemo(() => { + if (!config || !plotData) return ''; + return [ + selectedSample, + config.shownGene, + config.truncatedValues, + config.colour.gradient, + config.colour.reverseCbar, + config.colour.toggleInvert, + plotData.length, + ].join(':'); + }, [ + selectedSample, + config?.shownGene, + config?.truncatedValues, + config?.colour?.gradient, + config?.colour?.reverseCbar, + config?.colour?.toggleInvert, + plotData, + ]); + + // ── Segmentation overlay colour map (per-cell), recomputed on colour change ── + const segmentationsAvailableGuess = segmentationZarrUrls?.length > 0; + const cellColorMap = useMemo(() => { + if (!segmentationsAvailableGuess || !config || !plotData?.length + || !cellSets.accessible || !embeddingData?.length || !selectedSample) { + return null; + } + const filteredCellIds = filterCells(cellSets, selectedSample); + const activeData = config.truncatedValues ? truncatedPlotData : plotData; + return buildFeatureCellColorMap(filteredCellIds, activeData, embeddingData, config); + // colorSignature already captures gene/truncation/colour/sample/length + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorSignature, segmentationsAvailableGuess, cellSets.accessible, embeddingData]); + + // ── Viewport streaming (base + detail tiles for tissue & segmentation) ────── + const segEntry = useMemo( + () => segmentationZarrUrls?.find(({ sampleId }) => sampleId === selectedSample), + [segmentationZarrUrls, selectedSample], + ); + const imageEntry = useMemo( + () => omeZarrUrls?.find(({ sampleId }) => sampleId === selectedSample), + [omeZarrUrls, selectedSample], + ); + // undefined while probing, null when confirmed unavailable + const segmentationUrl = segmentationZarrUrls === null + ? undefined + : (segEntry?.url ?? null); - setImageUrls(newImageUrls); - }; + const { + imageDims, + segmentationsAvailable, + segProbeDone, + tissueImageData, + segOverlayData, + onViewportChange, + ready, + } = useSpatialStream({ + experimentId, + sampleId: selectedSample, + omeZarrUrl: imageEntry?.url ?? null, + segmentationUrl, + plotWidth: config?.dimensions?.width, + plotHeight: config?.dimensions?.height, + showImage: config?.showImage ?? true, + colorMap: cellColorMap, + colorKey: colorSignature, + opacity: (config?.marker?.opacity ?? 10) / 10, + outline: config?.marker?.outline ?? false, + }); + + // mouse zoom/pan → persist the resulting axes range into the plot config + // (debounced) so zoom survives navigation/sessions, AND stream a sharper viewport + // tile. axesRanges is excluded from the spec signature below, so writing it does + // NOT rebuild the view. + const persistZoom = useMemo(() => _.debounce((axesRanges) => { + onZoomChangeRef.current(axesRanges); + }, 250), []); + const onZoomDomUpdate = (name, value) => { + if (config?.miniPlot) return; // mini previews never zoom/stream — always full extent + const [xdom, ydom] = value; + onViewportChange(xdom, ydom); + persistZoom({ + xAxisAuto: false, + xMin: xdom[0], + xMax: xdom[1], + yAxisAuto: false, + yMin: ydom[0], + yMax: ydom[1], + }); + }; - loadImageData(); - }, [omeZarrUrls, selectedSample]); + // Re-apply the persisted zoom onto a freshly (re)built view, and stream the + // detail tile for that restored viewport. Mini previews skip this so they always + // show the zoomed-out slide, even though they share the main plot's axesRanges. + const axesRangesRef = useRef(config?.axesRanges); + axesRangesRef.current = config?.axesRanges; + const isMiniPlot = config?.miniPlot; + const wheelRef = usePreventWheelScroll(!isMiniPlot); + const restoreZoom = useCallback((view) => { + if (isMiniPlot) return; + const ar = axesRangesRef.current; + if (ar && ar.xAxisAuto === false) { + view.signal('initXdom', [ar.xMin, ar.xMax]).signal('initYdom', [ar.yMin, ar.yMax]).runAsync(); + onViewportChange([ar.xMin, ar.xMax], [ar.yMin, ar.yMax]); + } else { + // No persisted zoom (e.g. after Reset Plot): the rebuilt view is at full extent + // (its initXdom/initYdom signals). Tell the streamer so it re-streams the whole + // slide — otherwise it keeps the pre-reset zoomed-in tiles and only that region + // shows. + onViewportChange(view.signal('initXdom'), view.signal('initYdom')); + } + }, [onViewportChange, isMiniPlot]); + // ── Data loading dispatch ─────────────────────────────────────────────────── useEffect(() => { dispatch(loadCellSets(experimentId)); }, [omeZarrUrls]); useEffect(() => { - if (!embeddingSettings) { - dispatch(loadProcessingSettings(experimentId)); - } - + if (!embeddingSettings) dispatch(loadProcessingSettings(experimentId)); if (!embeddingData && embeddingSettings?.method) { dispatch(loadEmbedding(experimentId, EMBEDDING_TYPE)); } }, [embeddingSettings?.method]); + // ── Spec generation ───────────────────────────────────────────────────────── + // Tiles + overlay are NOT part of the spec — they stream in via the `data` prop — + // so zoom/recolour update the view in place without rebuilding it. + + // Spec signature EXCLUDING axesRanges and (when segmentation is shown) the + // overlay-only opacity/outline — so persisting mouse zoom into axesRanges and + // tweaking opacity/outline never regenerate the spec and thus never rebuild the + // view. Genuine changes (gene, gradient, dimensions, sample, …) still respec. + const specSignature = useMemo(() => { + if (!config) return ''; + const c = { ...config, axesRanges: undefined }; + if (segmentationsAvailable && c.marker) { + c.marker = { ...c.marker, opacity: undefined, outline: undefined }; + } + return JSON.stringify(c); + }, [config, segmentationsAvailable]); useEffect(() => { - if (!embeddingLoading + // Image dims not ready / segmentation availability not yet known → loader + if (!ready || !imageDims || !segProbeDone) { + setPlotSpec({}); + return; + } + + if ( + !embeddingLoading && !embeddingError && config && selectedSample && plotData?.length > 0 && cellSets.accessible && embeddingData?.length - && imageUrls[selectedSample]) { - const spec = generateSpec( - config, - EMBEDDING_TYPE, - imageUrls[selectedSample], - generateData( - cellSets, - selectedSample, - config.truncatedValues ? truncatedPlotData : plotData, - embeddingData, - ), - ); + ) { + const activeData = config.truncatedValues ? truncatedPlotData : plotData; + const specData = generateData(cellSets, selectedSample, activeData, embeddingData); - setPlotSpec(spec); + setPlotSpec(generateSpec( + config, EMBEDDING_TYPE, imageDims, specData, segmentationsAvailable, + )); } - }, [config, plotData, embeddingData, cellSets, embeddingLoading, imageUrls, selectedSample]); - + }, [ + specSignature, plotData, embeddingData, cellSets, embeddingLoading, + selectedSample, segmentationsAvailable, segProbeDone, imageDims, ready, + ]); + + // tissue + overlay tiles streamed to Vega in place (no view rebuild) via `data` + const vegaData = useMemo(() => ({ + tissueImageData, + segOverlayData, + }), [tissueImageData, segOverlayData]); + + // ── Render ────────────────────────────────────────────────────────────────── const render = () => { if (error) { return ( @@ -149,8 +383,8 @@ const SpatialFeaturePlot = (props) => { if (cellSets.error) { return ( { loadCellSets(experimentId); }} + error={cellSets.error} + onClick={() => { dispatch(loadCellSets(experimentId)); }} /> ); } @@ -158,18 +392,20 @@ const SpatialFeaturePlot = (props) => { if (embeddingError) { return ( { loadEmbedding(experimentId, EMBEDDING_TYPE); }} + error={embeddingError} + onClick={() => { dispatch(loadEmbedding(experimentId, EMBEDDING_TYPE)); }} /> ); } - if (!config + if ( + !config || loading || !cellSets.accessible || embeddingLoading || Object.keys(plotSpec).length === 0 - || !plotData?.length) { + || !plotData?.length + ) { return (
@@ -179,16 +415,21 @@ const SpatialFeaturePlot = (props) => { return (
- +
+ +
); }; - return ( - <> - {render()} - - ); + return <>{render()}; }; SpatialFeaturePlot.defaultProps = { @@ -197,6 +438,8 @@ SpatialFeaturePlot.defaultProps = { plotData: null, truncatedPlotData: null, actions: true, + onZoomChange: () => { }, + onSampleDefault: () => { }, }; SpatialFeaturePlot.propTypes = { @@ -204,13 +447,12 @@ SpatialFeaturePlot.propTypes = { config: PropTypes.object, plotData: PropTypes.array, truncatedPlotData: PropTypes.array, - actions: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.object, - ]), + actions: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), loading: PropTypes.bool.isRequired, error: PropTypes.bool.isRequired, reloadPlotData: PropTypes.func, + onZoomChange: PropTypes.func, + onSampleDefault: PropTypes.func, }; export default SpatialFeaturePlot; diff --git a/src/components/plots/SpatialFeatureReduxWrapper.jsx b/src/components/plots/SpatialFeatureReduxWrapper.jsx index 50d1b6f51c..35ec0166dc 100644 --- a/src/components/plots/SpatialFeatureReduxWrapper.jsx +++ b/src/components/plots/SpatialFeatureReduxWrapper.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { loadGeneExpression } from 'redux/actions/genes'; +import { updatePlotConfig } from 'redux/actions/componentConfig'; import SpatialFeaturePlot from './SpatialFeaturePlot'; // wrapper component used in plots and tables @@ -28,6 +29,10 @@ const SpatialFeatureReduxWrapper = (props) => { reloadPlotData={() => dispatch(loadGeneExpression( experimentId, [config?.shownGene], plotUuid, ))} + onZoomChange={(axesRanges) => dispatch(updatePlotConfig(plotUuid, { axesRanges }))} + onSampleDefault={(selectedSample) => dispatch( + updatePlotConfig(plotUuid, { selectedSample }), + )} /> ); }; diff --git a/src/components/plots/SpatialMoleculePlot.jsx b/src/components/plots/SpatialMoleculePlot.jsx new file mode 100644 index 0000000000..ba434ec6e8 --- /dev/null +++ b/src/components/plots/SpatialMoleculePlot.jsx @@ -0,0 +1,1139 @@ +import React, { + useState, useEffect, useMemo, useRef, useCallback, +} from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import dynamic from 'next/dynamic'; +import PropTypes from 'prop-types'; +import { Empty, Dropdown, Button } from 'antd'; +import { saveAs } from 'file-saver'; +import _ from 'lodash'; + +import { OrthographicView, OrthographicViewport, COORDINATE_SYSTEM } from '@deck.gl/core'; +import { ScatterplotLayer } from '@deck.gl/layers'; + +import { loadCellSets } from 'redux/actions/cellSets'; +import loadGeneList from 'redux/actions/genes/loadGeneList'; +import getHighestDispersionGenes from 'utils/getHighestDispersionGenes'; +import { getCellSets } from 'redux/selectors'; +import { filterCells } from 'utils/plotSpecs/generateSpatialFeatureSpec'; +import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; +import loadMoleculeNodes, { loadMoleculeMeta } from 'utils/spatial/loadMoleculeNodes'; +import { resolveGeneColors } from 'utils/spatial/moleculeColors'; +import ZipFileStore from 'components/data-exploration/spatial/ZipFileStore'; +import parseColor from 'components/data-exploration/parseColor'; +import { + BITMASK_LUT_SIZE, + makeBitmaskLayer, +} from 'components/data-exploration/spatial/bitmaskLayers'; +import { loadOmeZarrGrid } from 'components/data-exploration/spatial/loadOmeZarr'; +import { + niceTicks, formatTick, isDarkColor, chromeToSvg, drawChromeToCanvas, +} from 'utils/spatial/deckPlotChrome'; +import { root as zarrRoot } from 'zarrita'; + +import PlatformError from '../PlatformError'; +import Loader from '../Loader'; + +// Default number of genes to show when none are selected — a molecule plot over +// ALL genes (hundreds) would read the whole root tile, so (like the other gene +// plots) we start from a small set the user can edit. +const DEFAULT_GENE_COUNT = 5; + +// Default segmentation-outline colour/opacity + fallback molecule colour. +const SEG_GREY = [200, 200, 200]; +const SEG_OUTLINE_COLOUR = '#CECBCB'; +const SEG_OUTLINE_OPACITY = 0.05; + +// Gap between the legend and the plot (Vega's default legend offset is ~18; a touch +// more reads better against the dense point cloud). +const LEGEND_PAD = 24; + +// deck.gl is client-only (WebGL); load it dynamically with SSR disabled. +const DeckGL = dynamic(() => import('@deck.gl/react').then((mod) => mod.DeckGL), { ssr: false }); + +// Build the RGBA LUT (indexed by cellId+1, the bitmask pixel value) for the filtered +// cells of the selected sample — every visible cell renders the given flat colour. +const buildSegLUT = (cellIds, [r, g, b]) => { + const lut = new Uint8Array(BITMASK_LUT_SIZE * BITMASK_LUT_SIZE * 4); + cellIds.forEach((cellId) => { + const pixelValue = Number(cellId) + 1; + if (pixelValue <= 0 || pixelValue >= BITMASK_LUT_SIZE * BITMASK_LUT_SIZE) return; + lut[pixelValue * 4] = r; + lut[pixelValue * 4 + 1] = g; + lut[pixelValue * 4 + 2] = b; + lut[pixelValue * 4 + 3] = 255; + }); + return lut; +}; + +// Fit an OrthographicView to a micron bbox. Uses INDEPENDENT x/y zoom so the data +// fills the whole plot box (the same stretch-to-dimensions effect as the Vega +// spatial plots) instead of preserving the data aspect ratio. +const fitBboxToView = (bbox, width, height) => { + const [x0, y0, x1, y1] = bbox; + if (![x0, y0, x1, y1].every(Number.isFinite) || !width || !height) return null; + const extentX = Math.max(x1 - x0, 1); + const extentY = Math.max(y1 - y0, 1); + return { + target: [(x0 + x1) / 2, (y0 + y1) / 2, 0], + zoom: [Math.log2(width / extentX), Math.log2(height / extentY)], + }; +}; + +const zoomComponents = (zoom) => (Array.isArray(zoom) ? zoom : [zoom, zoom]); + +const SpatialMoleculePlot = (props) => { + const { + experimentId, config, onZoomChange, onSampleDefault, onDefaultGenes, onDefaultColors, + } = props; + + const dispatch = useDispatch(); + + const onSampleDefaultRef = useRef(onSampleDefault); + onSampleDefaultRef.current = onSampleDefault; + const onDefaultGenesRef = useRef(onDefaultGenes); + onDefaultGenesRef.current = onDefaultGenes; + const onDefaultColorsRef = useRef(onDefaultColors); + onDefaultColorsRef.current = onDefaultColors; + const onZoomChangeRef = useRef(onZoomChange); + onZoomChangeRef.current = onZoomChange; + // Only auto-pick default genes ONCE per artifact — respect a user who clears them. + const defaultGenesAppliedRef = useRef(false); + // Restore the persisted zoom (config.axesRanges) ONCE, for the first sample shown + // after mount — so navigating away and back keeps the camera. A later sample + // switch starts from the full extent. savedBboxRef mirrors the persisted region. + const initialZoomAppliedRef = useRef(false); + const savedBboxRef = useRef(null); + + const cellSets = useSelector(getCellSets()); + + const sampleIdsForFileUrls = useSelector((state) => state.experimentSettings.info.sampleIds); + const obj2sStatusRaw = useSelector( + (state) => state.backendStatus[experimentId]?.status?.obj2s?.status, + ); + const isObj2s = !_.isNil(obj2sStatusRaw) && obj2sStatusRaw !== 'NOT_CREATED'; + + // { geneName: { dispersions } } — used to pick the default genes by dispersion. + const geneData = useSelector((state) => state.genes?.properties?.data) || {}; + + const [moleculeUrls, setMoleculeUrls] = useState(null); + const [segmentationZarrUrls, setSegmentationZarrUrls] = useState(null); + const [selectedSample, setSelectedSample] = useState(); + const [moleculeMeta, setMoleculeMeta] = useState(null); + // { positions: Float32Array(n*2), colors: Uint8Array(n*3), count } for the + // ScatterplotLayer — typed-array binary attributes, no per-point objects. + const [moleculePoints, setMoleculePoints] = useState(null); + const [moleculesError, setMoleculesError] = useState(false); + const [segLoader, setSegLoader] = useState(null); + const [viewState, setViewState] = useState(null); + // briefly drops the molecule layer so the SVG export can snapshot an outline-only + // raster backdrop (the molecules are re-added as crisp vector circles on top). + const [hideMoleculesForExport, setHideMoleculesForExport] = useState(false); + + const width = config?.dimensions?.width ?? 500; + const height = config?.dimensions?.height ?? 500; + const isMiniPlot = Boolean(config?.miniPlot); + + // one ZipFileStore per artifact url, lazily reused across loads + const storesRef = useRef(new Map()); + // the current visible data region in MICRONS (dimension-independent). Updated on + // every gesture; on a dimension change we re-fit THIS region to the new plot box + // instead of resetting to the full extent (so the zoom survives a resize). Null = + // no region yet (fresh sample) → fall back to the full-extent fit. + const viewBboxRef = useRef(null); + // container of the whole plot (deck canvas + chrome) — read for the export snapshot + const containerRef = useRef(null); + + // ── Styling derived from the plot config (reproduces the Vega controls) ────── + const styling = useMemo(() => { + const axes = config?.axes ?? {}; + const font = config?.fontStyle?.font ?? 'sans-serif'; + // colour inversion: toggleInvert is the plot background; text/axes flip on dark. + const background = config?.colour?.toggleInvert ?? '#FFFFFF'; + const dark = isDarkColor(background); + const fontColour = dark ? '#FFFFFF' : (config?.fontStyle?.colour ?? '#000000'); + const legend = config?.legend ?? {}; + return { + font, + background, + fontColour, + title: { + text: config?.title?.text ?? '', + fontSize: config?.title?.fontSize ?? 15, + anchor: config?.title?.anchor ?? 'start', + dx: config?.title?.dx ?? 10, + }, + axes: { + xTitle: axes.xAxisText ?? '', + yTitle: axes.yAxisText ?? '', + titleFontSize: axes.titleFontSize ?? 13, + labelFontSize: axes.labelFontSize ?? 12, + xLabels: axes.xAxisLabels ?? true, + yLabels: axes.yAxisLabels ?? true, + rotateX: axes.xAxisRotateLabels ?? false, + gridOpacity: axes.gridOpacity ?? 0, + domainWidth: axes.domainWidth ?? 1, + }, + marker: { + // marker.size → point radius in px (1:1; default 0.4, range 0.1–10). + radius: config?.marker?.size ?? 0.4, + // Vega marker.opacity is a 0–10 slider → 0–1 layer opacity. + opacity: Math.min(1, Math.max(0.05, (config?.marker?.opacity ?? 8) / 10)), + }, + legend: { + enabled: legend.enabled ?? true, + position: ['top', 'bottom', 'left', 'right'].includes(legend.position) + ? legend.position : 'bottom', + title: legend.title ?? '', + titleFontSize: legend.titleFontSize ?? 12, + labelFontSize: legend.labelFontSize ?? 11, + }, + }; + }, [config?.axes, config?.fontStyle, config?.colour, config?.title, + config?.marker, config?.legend]); + + const deckglView = useMemo(() => new OrthographicView({ id: 'molecules', controller: true }), []); + + // ── Legend entries (gene → resolved colour) ───────────────────────────────── + // Colours are allocated in order without reuse (resolveGeneColors); a per-gene + // config override wins. Same resolution the scatter fill + seeding use, so the + // legend matches the points even before defaults are persisted. + const legendItems = useMemo(() => { + const selected = config?.selectedGenes ?? []; + const colors = resolveGeneColors(selected, config?.geneColors ?? {}); + return selected.map((gene) => ({ gene, color: colors[gene] })); + }, [config?.selectedGenes, config?.geneColors]); + + // ── Layout ────────────────────────────────────────────────────────────────── + // `dimensions` (width/height) sizes the PLOTTING AREA only — the deck canvas — to + // match the Vega spatial plots. Axes / title / legend are added as margins AROUND + // it, so the rendered container is larger than `dimensions`. + const layout = useMemo(() => { + if (isMiniPlot) { + return { + left: 0, + top: 0, + innerW: width, + innerH: height, + containerW: width, + containerH: height, + legendShown: false, + axesShown: false, + }; + } + const a = styling.axes; + const lp = styling.legend.position; + const hasTitle = styling.title.text.length > 0; + const legendShown = styling.legend.enabled && legendItems.length > 0; + const vertical = lp === 'left' || lp === 'right'; + const titleH = hasTitle ? styling.title.fontSize + 12 : 0; + // horizontal (top/bottom) legend → row height; vertical (left/right) → column width + const legendH = legendShown && !vertical + ? Math.max(styling.legend.labelFontSize, 12) + + (styling.legend.title ? styling.legend.titleFontSize + 4 : 0) + LEGEND_PAD + : 0; + const longestName = legendItems.reduce((m, { gene }) => Math.max(m, gene.length), 0); + const swatch = Math.max(8, styling.legend.labelFontSize); + const legendW = legendShown && vertical + ? swatch + 8 + longestName * styling.legend.labelFontSize * 0.6 + LEGEND_PAD + : 0; + const axisLeft = a.yLabels ? 56 : (a.yTitle ? 22 : 8); + const bottomAxis = a.xLabels ? 38 : (a.xTitle ? 22 : 8); + const left = axisLeft + (lp === 'left' ? legendW : 0); + const right = 8 + (lp === 'right' ? legendW : 0); + const top = titleH + (lp === 'top' ? legendH : 0) + 8; + const bottom = bottomAxis + (lp === 'bottom' ? legendH : 0); + // inner plot area = the configured dimensions; the container grows by the margins + return { + left, + top, + right, + bottom, + titleH, + legendH, + legendW, + axisLeftH: axisLeft, // y-axis gutter width (labels + title), excluding any legend + axisBottomH: bottomAxis, // x-axis gutter height, excluding any legend + legendShown, + axesShown: true, + innerW: width, + innerH: height, + containerW: width + left + right, + containerH: height + top + bottom, + }; + }, [styling, legendItems, width, height, isMiniPlot]); + + useEffect(() => { + dispatch(loadCellSets(experimentId)); + // gene dispersions power the default selection (top-N dispersion genes), like + // the dot-plot / marker-heatmap. Loads into state.genes.properties.data. + dispatch(loadGeneList(experimentId)); + }, [experimentId]); + + // ── Fetch molecule artifact URLs ───────────────────────────────────────────── + useEffect(() => { + if (!sampleIdsForFileUrls?.length) return; + (async () => { + try { + const results = await Promise.all( + // molecules_by_gene is built for every Xenium sample (transcripts.parquet + // is a required input). + sampleIdsForFileUrls.map((sampleId) => getSampleFileUrls(experimentId, sampleId, 'molecules_by_gene')), + ); + setMoleculeUrls(results.map((sampleUrls, i) => ({ + url: sampleUrls?.[0]?.url ?? null, + sampleId: isObj2s + ? (sampleUrls?.[0]?.fileId ?? sampleIdsForFileUrls[i]) + : sampleIdsForFileUrls[i], + }))); + } catch (e) { + console.error('[SpatialMoleculePlot] molecule URL fetch error:', e); + setMoleculeUrls([]); + } + })(); + }, [sampleIdsForFileUrls, experimentId, isObj2s]); + + // ── Fetch segmentation OME-Zarr URLs (the grey backdrop) ──────────────────── + useEffect(() => { + if (!sampleIdsForFileUrls?.length) return; + (async () => { + try { + const results = (await Promise.all( + sampleIdsForFileUrls.map((sampleId) => getSampleFileUrls(experimentId, sampleId, 'segmentations_ome_zarr_zip')), + )).flat(); + + setSegmentationZarrUrls(results.map(({ url, fileId }, i) => ({ + url, + sampleId: isObj2s ? fileId : sampleIdsForFileUrls[i], + }))); + } catch (_e) { + console.info('[SpatialMoleculePlot] segmentations_ome_zarr_zip not available'); + setSegmentationZarrUrls([]); // empty = confirmed unavailable + } + })(); + }, [sampleIdsForFileUrls, experimentId, isObj2s]); + + // ── Default selected sample ───────────────────────────────────────────────── + useEffect(() => { + if (!moleculeUrls || !config) return; + if (config.selectedSample) { + setSelectedSample(config.selectedSample); + } else { + const def = moleculeUrls[0]?.sampleId; + if (!def) return; + setSelectedSample(def); + onSampleDefaultRef.current(def); + } + }, [config, moleculeUrls]); + + const moleculesUrl = useMemo( + () => moleculeUrls?.find(({ sampleId }) => sampleId === selectedSample)?.url ?? null, + [moleculeUrls, selectedSample], + ); + const hasMolecules = Boolean(moleculesUrl); + + const segmentationUrl = useMemo( + () => segmentationZarrUrls?.find(({ sampleId }) => sampleId === selectedSample)?.url ?? null, + [segmentationZarrUrls, selectedSample], + ); + + const showSegOutlines = config?.showSegmentationOutlines ?? true; + const segOutlineColour = config?.segmentationOutlineColour ?? SEG_OUTLINE_COLOUR; + const segOutlineOpacity = config?.segmentationOutlineOpacity ?? SEG_OUTLINE_OPACITY; + const selectedGenes = config?.selectedGenes; + const hasSelectedGenes = (selectedGenes ?? []).length > 0; + const selectedGenesKey = useMemo(() => (selectedGenes ?? []).join('|'), [selectedGenes]); + + // Persisted zoom region (microns) from config.axesRanges; null when on auto + // (no saved zoom). Written by persistZoom as {xAxisAuto:false, xMin,yMin,xMax,yMax}. + const savedBbox = useMemo(() => { + const r = config?.axesRanges; + if (!r || r.xAxisAuto !== false) return null; + const bbox = [r.xMin, r.yMin, r.xMax, r.yMax]; + return bbox.every(Number.isFinite) ? bbox : null; + }, [config?.axesRanges]); + savedBboxRef.current = savedBbox; + + // ── Reset meta + points + latches when the selected sample changes ────────── + useEffect(() => { + setMoleculeMeta(null); + setMoleculePoints(null); + setSegLoader(null); + setViewState(null); + // Seed the view region from the persisted zoom for the first sample shown + // (navigation/reload restore); subsequent sample switches start full-extent. + if (selectedSample && !initialZoomAppliedRef.current) { + viewBboxRef.current = savedBboxRef.current; + initialZoomAppliedRef.current = true; + } else { + viewBboxRef.current = null; + } + defaultGenesAppliedRef.current = false; + }, [selectedSample]); + + const getStore = useCallback((url) => { + let store = storesRef.current.get(url); + if (!store) { + store = ZipFileStore.fromUrl(url); + storesRef.current.set(url, store); + } + return store; + }, []); + + // ── Bootstrap meta (genes palette + rootExtent) once the artifact is available ─ + useEffect(() => { + if (!hasMolecules) return undefined; + let cancelled = false; + (async () => { + try { + const meta = await loadMoleculeMeta(getStore(moleculesUrl)); + if (!cancelled) setMoleculeMeta(meta); + } catch (e) { + if (!cancelled) console.error('[SpatialMoleculePlot] meta load error:', e); + } + })(); + return () => { cancelled = true; }; + }, [moleculesUrl, hasMolecules, getStore]); + + // ── Segmentation bitmask loader (grey backdrop) ───────────────────────────── + // Loaded just like SpatialViewer (loadOmeZarrGrid, single-sample grid) so the + // bitmask shares the SAME absolute-micron frame as the molecules — no scaling + // or y-flip, the two overlay directly. + useEffect(() => { + if (!segmentationUrl || !showSegOutlines) { + setSegLoader(null); + return undefined; + } + let cancelled = false; + const root = zarrRoot(ZipFileStore.fromUrl(segmentationUrl)); + loadOmeZarrGrid([root], [1, 1]) + .then((loader) => { if (!cancelled) setSegLoader(loader); }) + .catch((e) => console.error('[SpatialMoleculePlot] segmentation loader error:', e)); + return () => { cancelled = true; }; + }, [segmentationUrl, showSegOutlines]); + + // ── Per-cell segmentation-outline colour LUT ──────────────────────────────── + const segCellIds = useMemo(() => { + if (!cellSets.accessible || !selectedSample) return null; + return filterCells(cellSets, selectedSample); + }, [cellSets.accessible, selectedSample]); + + const outlineLUT = useMemo( + () => (segCellIds ? buildSegLUT(segCellIds, parseColor(segOutlineColour)) : null), + [segCellIds, segOutlineColour], + ); + + // ── Default gene selection + per-gene colour seeding ──────────────────────── + // When the artifact loads with no genes selected, seed the top-DISPERSION panel + // genes (like the dot-plot / marker-heatmap), NOT alphabetical. Applied once; a + // user clearing all genes is respected. Also default each selected gene's colour + // from the palette (keyed on its stable feature_code) where unset. + useEffect(() => { + if (!moleculeMeta?.genes?.length) return; + if (!defaultGenesAppliedRef.current) { + if ((selectedGenes ?? []).length > 0) { + defaultGenesAppliedRef.current = true; + } else { + if (_.isEmpty(geneData)) return; + const panelGenes = new Set(moleculeMeta.genes.map(({ gene }) => gene)); + const panelDispersions = Object.fromEntries( + Object.entries(geneData).filter(([gene]) => panelGenes.has(gene)), + ); + const topGenes = getHighestDispersionGenes(panelDispersions, DEFAULT_GENE_COUNT) + .filter(Boolean); + if (!topGenes.length) return; + defaultGenesAppliedRef.current = true; + onDefaultGenesRef.current(topGenes); + return; + } + } + + // Persist a default colour for any selected gene that doesn't have one yet, + // taking the first available palette colour (resolveGeneColors). Persisting keeps + // the colour-picker swatches in sync and survives reloads. + const existing = config?.geneColors ?? {}; + const colors = resolveGeneColors(selectedGenes ?? [], existing); + const missing = {}; + (selectedGenes ?? []).forEach((gene) => { + // null (a removed gene re-added) or unset → needs a freshly allocated colour + if (!existing[gene]) missing[gene] = colors[gene]; + }); + if (Object.keys(missing).length > 0) { + onDefaultColorsRef.current({ ...existing, ...missing }); + } + }, [moleculeMeta, selectedGenes, config?.geneColors, geneData]); + + // ── Load all selected-gene molecules once (full extent, full depth) ────────── + // Like SpatialViewer: read every point for the selected genes across the whole + // artifact up front and let the GPU render them. Zoom/pan only moves the camera — + // the point set never changes, so points don't "appear" as you zoom in. + // Latest values via refs so the loader callback is created once. + const streamCtxRef = useRef({}); + streamCtxRef.current = { + moleculesUrl, + moleculeMeta, + selectedGenes, + geneColors: config?.geneColors, + }; + + const streamMolecules = useCallback(async () => { + const { + moleculesUrl: url, moleculeMeta: meta, selectedGenes: genes, geneColors, + } = streamCtxRef.current; + if (!url || !meta) return; + if (!genes || genes.length === 0) { setMoleculePoints(null); return; } + + const palette = meta.genes ?? []; + const byName = new Map(palette.map(({ gene, code }) => [gene, code])); + // resolve colours for the selected set (existing/overrides win, rest take the + // first available palette colour — matches the seeded/legend colours) + const resolved = resolveGeneColors(genes, geneColors ?? {}); + // code -> [r,g,b] + const colorByCode = new Map(); + const geneCodes = []; + genes.forEach((gene) => { + const code = byName.get(gene); + if (code === undefined) return; + geneCodes.push(code); + const [r, g, b] = parseColor(resolved[gene]); + colorByCode.set(code, [r, g, b]); + }); + if (!geneCodes.length) { setMoleculePoints(null); return; } + + try { + // Gene-partitioned artifact: range-read just the selected genes' entries + // (every point for each), exactly like SpatialViewer's single-gene overlay. + const result = await loadMoleculeNodes(getStore(url), { + genes: geneCodes, + }); + const { + count, x, y, featureCode, + } = result; + const positions = new Float32Array(count * 2); + const colors = new Uint8Array(count * 3); + for (let i = 0; i < count; i += 1) { + positions[i * 2] = x[i]; + positions[i * 2 + 1] = y[i]; + const [r, g, b] = colorByCode.get(featureCode[i]) ?? SEG_GREY; + colors[i * 3] = r; + colors[i * 3 + 1] = g; + colors[i * 3 + 2] = b; + } + setMoleculePoints({ positions, colors, count }); + setMoleculesError(false); + } catch (e) { + console.error('[SpatialMoleculePlot] molecule load error:', e); + setMoleculesError(true); + } + }, [getStore]); + + // full micron extent of the artifact (the zoomed-out overview bbox) + const rootBbox = useMemo(() => { + const ext = moleculeMeta?.rootExtent; + if (!ext) return null; + return [ext.x[0], ext.y[0], ext.x[1], ext.y[1]]; + }, [moleculeMeta]); + + // ── Initial molecule load + view fit (full extent) once meta + genes ready ── + useEffect(() => { + if (!hasMolecules || !rootBbox) return; + if (!hasSelectedGenes) { setMoleculePoints(null); return; } + streamMolecules(); + // streamMolecules reads the latest selection/colours via the ctx ref + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [moleculesUrl, hasMolecules, hasSelectedGenes, selectedGenesKey, rootBbox, + config?.geneColors]); + + // The full-extent fit for the current plot box (target + non-uniform zoom). This + // is the deck.gl mount/remount view state; it changes only when the data extent or + // the plot box changes (NOT on zoom), so it never fights an in-progress gesture. + const fitView = useMemo( + () => (rootBbox ? fitBboxToView(rootBbox, layout.innerW, layout.innerH) : null), + [rootBbox, layout.innerW, layout.innerH], + ); + // remount deck.gl (→ re-fit) when the sample, data extent or plot box changes. + // Also key on whether the segmentation loader is ready: the bitmask is a viv + // MultiscaleImageLayer (view-dependent tile rendering) that, when added to an + // already-mounted uncontrolled deck, doesn't draw until the first viewport change + // ("only shows on zoom"). Remounting once it's ready puts the layer in the initial + // render so it draws immediately; the camera is preserved (initialViewState below). + const segReady = Boolean(segLoader?.data); + const fitKey = `${selectedSample}:${layout.innerW}x${layout.innerH}:${rootBbox?.join(',')}:${segReady}`; + + // The view to (re)mount deck.gl with. On a remount (dimension/sample/seg-ready + // change) re-fit the LAST VISIBLE region to the current plot box, so a resize + // keeps the same zoom instead of snapping back to the full extent. With no region + // yet (fresh sample) we fall back to the full-extent fit. fitKey is the dep so this + // recomputes on exactly the remount moments, when viewBboxRef holds the latest region. + const mountViewState = useMemo(() => { + const bbox = viewBboxRef.current; + const refit = bbox ? fitBboxToView(bbox, layout.innerW, layout.innerH) : null; + return refit ?? fitView; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitKey, fitView]); + + // Mirror the mount view into the chrome's view state when it (re)fits; zoom/pan + // updates come through onViewStateChange. mountViewState changes only on a remount, + // so an in-progress gesture is never reset. + useEffect(() => { + if (mountViewState) setViewState(mountViewState); + }, [mountViewState]); + + // ── Persist zoom on settle (the camera only; the point set is fixed) ──────── + const persistZoom = useMemo(() => _.debounce((bbox) => { + onZoomChangeRef.current({ + xAxisAuto: false, xMin: bbox[0], xMax: bbox[2], yMin: bbox[1], yMax: bbox[3], + }); + }, 250), []); + + const bboxForViewState = useCallback((vs) => { + try { + const vp = new OrthographicViewport({ + width: layout.innerW, height: layout.innerH, target: vs.target, zoom: vs.zoom, + }); + const [ax, ay] = vp.unproject([0, 0]); + const [bx, by] = vp.unproject([layout.innerW, layout.innerH]); + return [Math.min(ax, bx), Math.min(ay, by), Math.max(ax, bx), Math.max(ay, by)]; + } catch (_e) { + return null; + } + }, [layout.innerW, layout.innerH]); + + // Uncontrolled deck.gl (like Embedding / SpatialViewer): the controller owns the + // camera and we just mirror it for the chrome + persistence — pan and zoom-in are + // entirely unrestricted. The ONLY constraint: you can't zoom out past the original + // (full-extent) view — once a gesture would zoom out beyond it, we snap back to the + // original view (deck.gl honours the value returned from onViewStateChange). + const onViewStateChange = useCallback(({ viewState: vs }) => { + let next = vs; + if (fitView) { + const [fzx, fzy] = zoomComponents(fitView.zoom); + const [vzx, vzy] = zoomComponents(vs.zoom); + if (vzx < fzx - 1e-3 || vzy < fzy - 1e-3) next = fitView; // zoomed out past original + } + setViewState(next); + const bbox = bboxForViewState(next); + if (bbox) { + // remember the visible region (microns) so a later resize re-fits to it + viewBboxRef.current = bbox; + if (!isMiniPlot) persistZoom(bbox); + } + return next; + }, [fitView, bboxForViewState, persistZoom, isMiniPlot]); + + // ── Layers ────────────────────────────────────────────────────────────────── + const bitmaskOutlineLayer = useMemo(() => { + if (!segLoader?.data || !showSegOutlines || !outlineLUT) return null; + return makeBitmaskLayer({ + id: 'molecule-seg-outline', + loader: segLoader.data, + cellColorData: outlineLUT, + hoveredCell: 0, + showOutlineOnly: true, + opacity: segOutlineOpacity, + }); + // fitKey dep → fresh instance on a deck.gl remount (see the molecule layer note) + }, [segLoader, showSegOutlines, outlineLUT, segOutlineOpacity, fitKey]); + + const moleculeScatterLayer = useMemo(() => { + // hideMoleculesForExport drops the layer for the outline-only export snapshot. + // It's a memo dependency so toggling it back ON builds a FRESH layer instance — + // deck.gl won't re-render a layer instance it has already finalised/removed. + if (!moleculePoints?.count || hideMoleculesForExport) return null; + const { positions, colors, count } = moleculePoints; + return new ScatterplotLayer({ + id: 'molecules', + data: { + length: count, + attributes: { + getPosition: { value: positions, size: 2 }, + getFillColor: { value: colors, size: 3 }, + }, + }, + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + radiusUnits: 'pixels', + getRadius: styling.marker.radius, + radiusMinPixels: 0, + radiusMaxPixels: 40, + opacity: styling.marker.opacity, + stroked: false, + filled: true, + pickable: false, + updateTriggers: { getRadius: [styling.marker.radius] }, + }); + // fitKey is a dep so a deck.gl remount (dimension/sample change) gets a FRESH + // layer instance — a finalised instance handed to the new deck won't render. + }, [moleculePoints, styling.marker.radius, styling.marker.opacity, + hideMoleculesForExport, fitKey]); + + const layers = useMemo( + () => [bitmaskOutlineLayer, moleculeScatterLayer].filter(Boolean), + [bitmaskOutlineLayer, moleculeScatterLayer], + ); + + // ── Chrome model (axes + title + legend) in container pixels ──────────────── + // One flat model of rects/lines/texts, consumed by the live SVG overlay AND the + // PNG / SVG exporters so they always match. deck.gl provides none of this. + const buildChromeModel = useCallback((vs) => { + const model = { rects: [], lines: [], texts: [] }; + if (isMiniPlot || !vs || !layout.axesShown) return model; + const { + left, top, innerW, innerH, containerW, containerH, + } = layout; + const right = left + innerW; + const bottom = top + innerH; + const s = styling; + const a = s.axes; + + let vp; + try { + vp = new OrthographicViewport({ + width: innerW, height: innerH, target: vs.target, zoom: vs.zoom, + }); + } catch (_e) { return model; } + const projX = (wx) => left + vp.project([wx, vs.target[1], 0])[0]; + const projY = (wy) => top + vp.project([vs.target[0], wy, 0])[1]; + + const bbox = bboxForViewState(vs); + if (!bbox) return model; + const [xMin, yMin, xMax, yMax] = bbox; + // grid line weight 0–10 → alpha 0 (transparent) … 1 (fully visible) + const gridAlpha = Math.min(1, Math.max(0, a.gridOpacity / 10)); + + // Axis LABELS only (not the rendering): anchor the data extent's bottom-left + // corner at (0,0). The deck view stays y-down so the image + molecules keep their + // native orientation; we just relabel — x measured rightward from the extent's + // left edge, y measured UPWARD from its bottom edge (so the origin reads + // bottom-left, like the Vega spatial plots). Tick POSITIONS are unchanged, so + // nothing moves and the image is not expanded — only the printed numbers change. + // world<->label maps. Ticks are generated in LABEL space (so 0 is a tick and the + // bottom-left reads 0,0) and mapped back to world coords for positioning. + const labelX = (t) => (rootBbox ? t - rootBbox[0] : t); + const labelY = (t) => (rootBbox ? rootBbox[3] - t : t); + const worldX = (l) => (rootBbox ? l + rootBbox[0] : l); + const worldY = (l) => (rootBbox ? rootBbox[3] - l : l); + + if (a.domainWidth > 0) { + model.lines.push({ + x1: left, y1: bottom, x2: right, y2: bottom, stroke: s.fontColour, width: a.domainWidth, + }); + model.lines.push({ + x1: left, y1: top, x2: left, y2: bottom, stroke: s.fontColour, width: a.domainWidth, + }); + } + + // tick density ≈ Vega's default (about one tick per ~40px of axis) + const xTickCount = Math.max(2, Math.round(innerW / 40)); + const yTickCount = Math.max(2, Math.round(innerH / 40)); + + niceTicks(labelX(xMin), labelX(xMax), xTickCount).forEach((l) => { + const x = projX(worldX(l)); + if (x < left - 0.5 || x > right + 0.5) return; + if (gridAlpha > 0) { + model.lines.push({ + x1: x, y1: top, x2: x, y2: bottom, stroke: s.fontColour, width: 1, opacity: gridAlpha, + }); + } + // tick marks + labels only when the x-axis labels are enabled + if (a.xLabels) { + model.lines.push({ + x1: x, y1: bottom, x2: x, y2: bottom + 4, stroke: s.fontColour, width: 1, + }); + model.texts.push({ + x, + y: bottom + 6, + text: formatTick(l), + anchor: a.rotateX ? 'end' : 'middle', + baseline: 'hanging', + size: a.labelFontSize, + color: s.fontColour, + font: s.font, + rotate: a.rotateX ? -45 : 0, + }); + } + }); + + // y label range runs from the bottom edge (labelY(yMax), the small value) up to + // the top edge (labelY(yMin)); ticks in label space → 0 lands on the bottom edge. + niceTicks(labelY(yMax), labelY(yMin), yTickCount).forEach((l) => { + const y = projY(worldY(l)); + if (y < top - 0.5 || y > bottom + 0.5) return; + if (gridAlpha > 0) { + model.lines.push({ + x1: left, y1: y, x2: right, y2: y, stroke: s.fontColour, width: 1, opacity: gridAlpha, + }); + } + // tick marks + labels only when the y-axis labels are enabled + if (a.yLabels) { + model.lines.push({ + x1: left - 4, y1: y, x2: left, y2: y, stroke: s.fontColour, width: 1, + }); + model.texts.push({ + x: left - 6, + y, + text: formatTick(l), + anchor: 'end', + baseline: 'middle', + size: a.labelFontSize, + color: s.fontColour, + font: s.font, + }); + } + }); + + // axis titles live in their gutter (between the plot and any legend band) + if (a.xTitle) { + model.texts.push({ + x: left + innerW / 2, + y: bottom + layout.axisBottomH - 4, + text: a.xTitle, + anchor: 'middle', + size: a.titleFontSize, + color: s.fontColour, + font: s.font, + }); + } + if (a.yTitle) { + model.texts.push({ + x: (left - layout.axisLeftH) + 12, + y: top + innerH / 2, + text: a.yTitle, + anchor: 'middle', + size: a.titleFontSize, + color: s.fontColour, + font: s.font, + rotate: -90, + }); + } + + if (s.title.text) { + let tx = s.title.dx; + if (s.title.anchor === 'middle') tx = containerW / 2; + else if (s.title.anchor === 'end') tx = containerW - s.title.dx; + model.texts.push({ + x: tx, + y: 4, + text: s.title.text, + anchor: s.title.anchor === 'middle' || s.title.anchor === 'end' ? s.title.anchor : 'start', + baseline: 'hanging', + size: s.title.fontSize, + color: s.fontColour, + font: s.font, + }); + } + + // legend — swatch + label per gene, in its reserved gutter. Horizontal row for + // top/bottom, vertical column for left/right. + if (layout.legendShown) { + const lp = s.legend.position; + const sw = Math.max(8, s.legend.labelFontSize); + const swatch = (x, y, color) => model.rects.push({ + x, y, w: sw, h: sw, fill: color, rx: sw / 2, + }); + const label = (x, y, text, size) => model.texts.push({ + x, y, text, anchor: 'start', baseline: 'middle', size, color: s.fontColour, font: s.font, + }); + + if (lp === 'left' || lp === 'right') { + // vertical column, TOP-aligned. Left: far-left gutter (left of the y-axis + // labels). Right: LEGEND_PAD past the plot's right edge. + const rowH = sw + 6; + const lx = lp === 'left' ? 4 : left + innerW + LEGEND_PAD; + let cy = top; + if (s.legend.title) { + label(lx, cy + s.legend.titleFontSize / 2, s.legend.title, s.legend.titleFontSize); + cy += s.legend.titleFontSize + 6; + } + legendItems.forEach(({ gene, color }) => { + swatch(lx, cy, color); + label(lx + sw + 4, cy + sw / 2, gene, s.legend.labelFontSize); + cy += rowH; + }); + } else { + // horizontal row, LEFT-aligned (starts at the plot's left edge). Top: below + // the title; bottom: below the x-axis labels. + const gap = 16; + const widths = legendItems.map( + ({ gene }) => sw + 4 + gene.length * s.legend.labelFontSize * 0.6, + ); + let cx = left; + const ly = lp === 'top' ? layout.titleH + 2 : containerH - sw - 2; + const midY = ly + sw / 2; + if (s.legend.title) { + label(cx, midY, s.legend.title, s.legend.titleFontSize); + cx += s.legend.title.length * s.legend.titleFontSize * 0.6 + 8; + } + legendItems.forEach(({ gene, color }, i) => { + swatch(cx, ly, color); + label(cx + sw + 4, midY, gene, s.legend.labelFontSize); + cx += widths[i] + gap; + }); + } + } + + return model; + }, [isMiniPlot, layout, styling, legendItems, bboxForViewState, rootBbox, width, height]); + + const chromeModel = useMemo(() => buildChromeModel(viewState), [buildChromeModel, viewState]); + + // ── Export (PNG / SVG) — composite the GL snapshot with the chrome model ──── + const getDeckCanvas = () => containerRef.current?.querySelector('canvas') ?? null; + + const downloadPng = useCallback(() => { + const canvas = getDeckCanvas(); + if (!canvas || !viewState) return; + const scale = 1; + const out = document.createElement('canvas'); + out.width = layout.containerW * scale; + out.height = layout.containerH * scale; + const ctx = out.getContext('2d'); + ctx.fillStyle = styling.background; + ctx.fillRect(0, 0, out.width, out.height); + ctx.drawImage( + canvas, + layout.left * scale, layout.top * scale, layout.innerW * scale, layout.innerH * scale, + ); + drawChromeToCanvas(ctx, buildChromeModel(viewState), scale); + out.toBlob((blob) => { if (blob) saveAs(blob, 'spatial_molecules.png'); }); + }, [viewState, layout, styling, buildChromeModel]); + + // Project the molecule points to screen px as vector circles, for a true-vector + // SVG export that stays crisp at any zoom (rather than a raster snapshot). + const buildMoleculeCircles = useCallback((vs) => { + if (!moleculePoints?.count || !vs) return []; + const { + left, top, innerW, innerH, + } = layout; + let vp; + try { + vp = new OrthographicViewport({ + width: innerW, height: innerH, target: vs.target, zoom: vs.zoom, + }); + } catch (_e) { return []; } + const { positions, colors, count } = moleculePoints; + const r = styling.marker.radius; + const out = []; + for (let i = 0; i < count; i += 1) { + const [sx, sy] = vp.project([positions[i * 2], positions[i * 2 + 1], 0]); + const cx = left + sx; + const cy = top + sy; + // keep only points inside the plot rect (the SVG clips, this trims the size) + if (cx >= left - r && cx <= left + innerW + r && cy >= top - r && cy <= top + innerH + r) { + out.push({ + cx: Number(cx.toFixed(2)), + cy: Number(cy.toFixed(2)), + r, + fill: `rgb(${colors[i * 3]},${colors[i * 3 + 1]},${colors[i * 3 + 2]})`, + }); + } + } + return out; + }, [moleculePoints, layout, styling.marker.radius]); + + // Snapshot the deck canvas with the molecule layer hidden, so the raster captures + // ONLY the segmentation outline. The outline tiles are already loaded in the live + // deck, so re-rendering without the molecules is a quick repaint. + const captureOutlineRaster = useCallback(() => new Promise((resolve) => { + if (!segLoader?.data || !showSegOutlines) { resolve(null); return; } + setHideMoleculesForExport(true); + // wait for deck to repaint without the molecule layer, then read the pixels + setTimeout(() => { + const canvas = getDeckCanvas(); + const url = canvas ? canvas.toDataURL('image/png') : null; + setHideMoleculesForExport(false); + resolve(url); + }, 80); + }), [segLoader, showSegOutlines]); + + const downloadSvg = useCallback(async () => { + if (!viewState) return; + // The segmentation outline is a raster bitmask, so embed an outline-ONLY GL + // snapshot as a backdrop (molecules hidden during capture). The molecules are + // then drawn on top as VECTOR circles, so the data stays crisp at any zoom and + // the raster underneath holds only the (soft) outline. + const imageHref = await captureOutlineRaster(); + const svg = chromeToSvg(buildChromeModel(viewState), { + width: layout.containerW, + height: layout.containerH, + background: styling.background, + imageHref, + imageRect: [layout.left, layout.top, layout.innerW, layout.innerH], + clipRect: [layout.left, layout.top, layout.innerW, layout.innerH], + points: buildMoleculeCircles(viewState), + pointsOpacity: styling.marker.opacity, + }); + saveAs(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }), 'spatial_molecules.svg'); + }, [viewState, layout, styling, buildChromeModel, buildMoleculeCircles, captureOutlineRaster]); + + const exportMenu = useMemo(() => ({ + items: [ + { key: 'png', label: 'Save as PNG' }, + { key: 'svg', label: 'Save as SVG' }, + ], + onClick: ({ key }) => (key === 'png' ? downloadPng() : downloadSvg()), + }), [downloadPng, downloadSvg]); + + // ── Render ────────────────────────────────────────────────────────────────── + const render = () => { + if (moleculesError) { + return ( + streamMolecules()} + /> + ); + } + + // Artifact present but the user cleared the gene selection. + if (hasMolecules && !hasSelectedGenes && moleculeMeta) { + return ( +
+ +
+ ); + } + + if (!config || !viewState) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +
+ {!isMiniPlot && ( + + {chromeModel.rects.map((r, i) => ( + + ))} + {chromeModel.lines.map((l, i) => ( + + ))} + {chromeModel.texts.map((t, i) => ( + + {t.text} + + ))} + + )} +
+ {!isMiniPlot && ( + + + + )} +
+ ); + }; + + return <>{render()}; +}; + +SpatialMoleculePlot.defaultProps = { + config: null, + onZoomChange: () => { }, + onSampleDefault: () => { }, + onDefaultGenes: () => { }, + onDefaultColors: () => { }, +}; + +SpatialMoleculePlot.propTypes = { + experimentId: PropTypes.string.isRequired, + config: PropTypes.object, + onZoomChange: PropTypes.func, + onSampleDefault: PropTypes.func, + onDefaultGenes: PropTypes.func, + onDefaultColors: PropTypes.func, +}; + +export default SpatialMoleculePlot; diff --git a/src/components/plots/SpatialMoleculeReduxWrapper.jsx b/src/components/plots/SpatialMoleculeReduxWrapper.jsx new file mode 100644 index 0000000000..e2a21cfa9f --- /dev/null +++ b/src/components/plots/SpatialMoleculeReduxWrapper.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import PropTypes from 'prop-types'; +import { updatePlotConfig } from 'redux/actions/componentConfig'; +import SpatialMoleculePlot from './SpatialMoleculePlot'; + +// wrapper component used in plots and tables: derives the plot config from redux +// and persists zoom / default-sample writes back. +const SpatialMoleculeReduxWrapper = (props) => { + const { experimentId, actions, plotUuid } = props; + + const dispatch = useDispatch(); + const config = useSelector((state) => state.componentConfig[plotUuid]?.config); + + return ( + dispatch(updatePlotConfig(plotUuid, { axesRanges }))} + onSampleDefault={(selectedSample) => dispatch( + updatePlotConfig(plotUuid, { selectedSample }), + )} + onDefaultGenes={(selectedGenes) => dispatch( + updatePlotConfig(plotUuid, { selectedGenes }), + )} + onDefaultColors={(geneColors) => dispatch( + updatePlotConfig(plotUuid, { geneColors }), + )} + /> + ); +}; + +SpatialMoleculeReduxWrapper.defaultProps = { + actions: true, +}; +SpatialMoleculeReduxWrapper.propTypes = { + experimentId: PropTypes.string.isRequired, + actions: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), + plotUuid: PropTypes.string.isRequired, +}; +export default SpatialMoleculeReduxWrapper; diff --git a/src/components/plots/SpatialOutlierFilterPlot.jsx b/src/components/plots/SpatialOutlierFilterPlot.jsx new file mode 100644 index 0000000000..25accbfbb6 --- /dev/null +++ b/src/components/plots/SpatialOutlierFilterPlot.jsx @@ -0,0 +1,357 @@ +import React, { + useState, useEffect, useMemo, useRef, useCallback, +} from 'react'; +import PropTypes from 'prop-types'; +import { Vega } from 'react-vega'; +import * as vega from 'vega'; +import 'vega-webgl-renderer'; +import _ from 'lodash'; + +import { Spin } from 'antd'; +import { useSelector } from 'react-redux'; +import colors from 'utils/styling/colors'; +import { generateSpec } from 'utils/plotSpecs/generateSpatialFeatureSpec'; +import { getSampleFileUrls } from 'utils/data-management/downloadSampleFile'; +import { imagelessTechs } from 'utils/constants'; +import useSpatialStream from './useSpatialStream'; +import usePreventWheelScroll from './usePreventWheelScroll'; + +const EMBEDDING_TYPE = 'images'; +// 3-element colours (no baked alpha) so colorSegmentationOverlay applies the +// opacity slider to BOTH outliers and non-outliers at the same opacity. +const OUTLIER_COLOR = [255, 0, 0]; // red +const KEPT_COLOR = [211, 211, 211]; // light grey + +const isOutlier = (zscore, threshold, direction) => ( + direction === 'upper' ? zscore > threshold : zscore < -threshold +); + +// Build Map for the outlier plot: outliers red, everything else +// grey — both rendered at the slider opacity. +const buildOutlierCellColorMap = (plotData, threshold, direction) => { + const map = new Map(); + (plotData || []).forEach(({ cellId, zscore }) => { + if (zscore === undefined || zscore === null) return; + map.set(cellId, isOutlier(zscore, threshold, direction) ? OUTLIER_COLOR : KEPT_COLOR); + }); + return map; +}; + +// Parse a CSS colour string returned by vega.scheme interpolators into [r, g, b]. +const parseCssColor = (cssColor) => { + if (!cssColor) return [128, 128, 128]; + const rgbMatch = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(cssColor); + if (rgbMatch) { + return [parseInt(rgbMatch[1], 10), parseInt(rgbMatch[2], 10), parseInt(rgbMatch[3], 10)]; + } + const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(cssColor); + if (hexMatch) { + return [parseInt(hexMatch[1], 16), parseInt(hexMatch[2], 16), parseInt(hexMatch[3], 16)]; + } + return [128, 128, 128]; +}; + +// Build Map from the metric values, using the same colour scale +// logic as the Vega spec so the overlay matches the legend exactly. +const buildMetricCellColorMap = (plotData, config) => { + const map = new Map(); + if (!plotData?.length) return map; + + // reduce instead of Math.min/max(...array): spreading large Visium HD arrays + // into variadic functions exhausts the call stack. + let minVal = Infinity; + let maxVal = -Infinity; + plotData.forEach(({ value }) => { + if (value === undefined || value === null) return; + if (value < minVal) minVal = value; + if (value > maxVal) maxVal = value; + }); + const range = maxVal - minVal || 1; + + const schemeName = config.colour.gradient === 'default' + ? (config.colour.toggleInvert === '#FFFFFF' ? 'purplered' : 'darkgreen') + : config.colour.gradient; + // spectral defaults to reversed; reverseCbar flips that (XOR) — see generateSpatialFeatureSpec + const shouldReverse = (config.colour.gradient === 'spectral') !== Boolean(config.colour.reverseCbar); + const interpolator = vega.scheme(schemeName); + if (typeof interpolator !== 'function') return map; + + plotData.forEach(({ cellId, value }) => { + if (value === undefined || value === null) return; + let t = (value - minVal) / range; + if (shouldReverse) t = 1 - t; + map.set(cellId, parseCssColor(interpolator(t))); + }); + + return map; +}; + +/** + * Data-processing spatial filter plot. Two modes: + * - 'metric': tissue slide with segmentations coloured by the metric value. + * - 'outlier': segmentations beyond the z-score threshold filled red, the rest + * grey (recomputed live as the threshold changes). + * Per-sample — all the data it needs is in `plotData` ([{ cellId, value, zscore, x, y }]). + */ +const SpatialOutlierFilterPlot = (props) => { + const { + experimentId, sampleId, config, plotData, threshold, direction, mode, actions, + onZoomChange, cacheId, + } = props; + + const [plotSpec, setPlotSpec] = useState({}); + const [omeZarrUrl, setOmeZarrUrl] = useState(null); + // undefined while probing, null = unavailable, string = available + const [segmentationUrl, setSegmentationUrl] = useState(undefined); + + // keep the latest onZoomChange in a ref so the debounced persister always calls + // the current callback without re-creating the debounce + const onZoomChangeRef = useRef(onZoomChange); + onZoomChangeRef.current = onZoomChange; + + const technology = useSelector((state) => state.samples?.[sampleId]?.type); + const isImageless = imagelessTechs.includes(technology); + + // ── Fetch histology image URL ────────────────────────────────────────────── + useEffect(() => { + if (!sampleId) return; + // imageless techs (e.g. Xenium) have no tissue image: skip the ome_zarr_zip + // request (it would 404); image dims come from the segmentation pyramid. + if (isImageless) { + setOmeZarrUrl(null); + return; + } + (async () => { + try { + const results = await getSampleFileUrls(experimentId, sampleId, 'ome_zarr_zip'); + setOmeZarrUrl(results?.[0]?.url ?? null); + } catch (e) { + console.error('[SpatialOutlierFilterPlot] error fetching image URL:', e); + } + })(); + }, [experimentId, sampleId, isImageless]); + + // ── Fetch segmentation URL (optional) ────────────────────────────────────── + useEffect(() => { + if (!sampleId) return; + (async () => { + try { + const results = await getSampleFileUrls(experimentId, sampleId, 'segmentations_ome_zarr_zip'); + setSegmentationUrl(results?.[0]?.url ?? null); + } catch (_e) { + console.info('[SpatialOutlierFilterPlot] segmentations_ome_zarr_zip not available — centroid fallback'); + setSegmentationUrl(null); + } + })(); + }, [experimentId, sampleId]); + + const colorSignature = useMemo(() => [ + config?.colour?.gradient, + config?.colour?.reverseCbar, + config?.colour?.toggleInvert, + plotData?.length, + ].join(':'), [config?.colour, plotData]); + + // colour map + key — threshold only affects the outlier view; cacheId/mode keep + // filters that may share the same length/colours distinct. + const colorKey = [ + cacheId, mode, direction, mode === 'outlier' ? threshold : 'metric', colorSignature, + ].join(':'); + const cellColorMap = useMemo(() => { + if (!plotData?.length || !config) return null; + return mode === 'outlier' + ? buildOutlierCellColorMap(plotData, threshold, direction) + : buildMetricCellColorMap(plotData, config); + // colorKey captures mode/threshold/direction/colours/length + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorKey, plotData]); + + // ── Viewport streaming (base + detail tiles for tissue & segmentation) ────── + const { + imageDims, + segmentationsAvailable, + segProbeDone, + tissueImageData, + segOverlayData, + onViewportChange, + ready, + } = useSpatialStream({ + experimentId, + sampleId, + omeZarrUrl, + segmentationUrl, + plotWidth: config?.dimensions?.width, + plotHeight: config?.dimensions?.height, + showImage: config?.showImage ?? false, + colorMap: cellColorMap, + colorKey, + opacity: (config?.marker?.opacity ?? 10) / 10, + outline: config?.marker?.outline ?? false, + }); + + // mouse zoom/pan → persist axes range (debounced) AND stream a sharper tile. + const persistZoom = useMemo(() => _.debounce((axesRanges) => { + onZoomChangeRef.current(axesRanges); + }, 250), []); + const onZoomDomUpdate = (name, value) => { + if (config?.miniPlot) return; // mini previews never zoom/stream — always full extent + const [xdom, ydom] = value; + onViewportChange(xdom, ydom); + persistZoom({ + xAxisAuto: false, + xMin: xdom[0], + xMax: xdom[1], + yAxisAuto: false, + yMin: ydom[0], + yMax: ydom[1], + }); + }; + + // Re-apply persisted zoom onto a freshly (re)built view + stream its detail tile. + // Mini previews skip this entirely so they always show the zoomed-out slide, even + // when the main plot (which shares this config's axesRanges) is zoomed in. + const axesRangesRef = useRef(config?.axesRanges); + axesRangesRef.current = config?.axesRanges; + const isMiniPlot = config?.miniPlot; + const wheelRef = usePreventWheelScroll(!isMiniPlot); + const restoreZoom = useCallback((view) => { + if (isMiniPlot) return; + const ar = axesRangesRef.current; + if (ar && ar.xAxisAuto === false) { + view.signal('initXdom', [ar.xMin, ar.xMax]).signal('initYdom', [ar.yMin, ar.yMax]).runAsync(); + onViewportChange([ar.xMin, ar.xMax], [ar.yMin, ar.yMax]); + } else { + // No persisted zoom (e.g. after Reset Plot): the rebuilt view is at full extent + // (its initXdom/initYdom signals). Tell the streamer so it re-streams the whole + // slide — otherwise it keeps the pre-reset zoomed-in tiles and only that region + // shows. + onViewportChange(view.signal('initXdom'), view.signal('initYdom')); + } + }, [onViewportChange, isMiniPlot]); + + // ── Spec generation ───────────────────────────────────────────────────────── + // Tiles + overlay stream in via the `data` prop — recolour (e.g. dragging the + // threshold) and zoom update the view in place without rebuilding it. + const specSignature = useMemo(() => { + if (!config) return ''; + const c = { ...config, axesRanges: undefined }; + if (segmentationsAvailable && c.marker) { + c.marker = { ...c.marker, opacity: undefined, outline: undefined }; + } + return JSON.stringify(c); + }, [config, segmentationsAvailable]); + useEffect(() => { + if (!ready || !imageDims || !segProbeDone || !config || !plotData?.length) { + setPlotSpec({}); + return; + } + + // Centroid-fallback data (used only when no segmentation zarr is available) + const specData = plotData.map(({ x, y, value }) => ({ x, y, value })); + + const spec = generateSpec( + config, EMBEDDING_TYPE, imageDims, specData, segmentationsAvailable, + ); + + // The outlier view's continuous metric legend is meaningless (cells are coloured + // red/grey client-side). Replace it with a categorical "Status" legend. + if (mode === 'outlier') { + const showLegend = config.legend?.enabled !== false && !config.miniPlot; + spec.scales = [ + ...(spec.scales || []), + { + name: 'outlierStatus', + type: 'ordinal', + domain: ['kept', 'outlier'], + range: ['rgb(211, 211, 211)', 'rgb(255, 0, 0)'], + }, + ]; + spec.legends = showLegend ? [{ + fill: 'outlierStatus', + type: 'symbol', + title: config.legend?.title || 'Status', + orient: config.legend?.position || 'right', + direction: ['left', 'right'].includes(config.legend?.position) ? 'vertical' : 'horizontal', + titleColor: config.colour.masterColour, + labelColor: config.colour.masterColour, + titleFontSize: config.legend?.titleFontSize, + labelFontSize: config.legend?.labelFontSize, + symbolType: 'circle', + symbolSize: 100, + offset: 40, + }] : []; + } + + setPlotSpec(spec); + }, [ + specSignature, plotData, mode, segmentationsAvailable, segProbeDone, imageDims, ready, + ]); + + // tissue + overlay tiles streamed to Vega in place (no view rebuild) via `data` + const vegaData = useMemo(() => ({ + tissueImageData, + segOverlayData, + }), [tissueImageData, segOverlayData]); + + if (Object.keys(plotSpec).length === 0 || !plotData?.length) { + // Mini preview (in the plot selector): render a fixed-size empty box — no spinner + // — so the preview tile keeps the final plot's footprint and doesn't reflow or + // shift position as the image renders in. + if (config?.miniPlot) { + return ( +
+ ); + } + // plain spinner: this step is API/UI processing (image + overlay), not a backend + // worker task. Red to match the app's other loaders. + return ( +
+ + +
+ ); + } + + return ( +
+
+ +
+
+ ); +}; + +SpatialOutlierFilterPlot.defaultProps = { + config: null, + plotData: null, + actions: true, + threshold: 3, + direction: 'lower', + mode: 'metric', + onZoomChange: () => { }, + cacheId: '', +}; + +SpatialOutlierFilterPlot.propTypes = { + experimentId: PropTypes.string.isRequired, + sampleId: PropTypes.string.isRequired, + config: PropTypes.object, + plotData: PropTypes.array, + cacheId: PropTypes.string, + threshold: PropTypes.number, + direction: PropTypes.oneOf(['lower', 'upper']), + mode: PropTypes.oneOf(['metric', 'outlier']), + actions: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), + onZoomChange: PropTypes.func, +}; + +export default SpatialOutlierFilterPlot; diff --git a/src/components/plots/TrajectoryAnalysisPlot.jsx b/src/components/plots/TrajectoryAnalysisPlot.jsx index 6df294319a..4333c0dd3b 100644 --- a/src/components/plots/TrajectoryAnalysisPlot.jsx +++ b/src/components/plots/TrajectoryAnalysisPlot.jsx @@ -279,6 +279,7 @@ const TrajectoryAnalysisPlot = forwardRef((props, ref) => { { return (
- +
); }; diff --git a/src/components/plots/getImageUrls.js b/src/components/plots/getImageUrls.js index 4194408ada..ad7ad57e6a 100644 --- a/src/components/plots/getImageUrls.js +++ b/src/components/plots/getImageUrls.js @@ -1,52 +1,157 @@ -import { loadOmeZarr } from 'components/data-exploration/spatial/loadOmeZarr'; -import { root as zarrRoot } from 'zarrita'; -import ZipFileStore from 'components/data-exploration/spatial/ZipFileStore'; +import { get, slice } from 'zarrita'; +import { openOmePyramid, resolvePyramidRegion } from './zarrPyramid'; +import { registerDrawable } from './loadSegmentationOverlay'; -// Load OME-Zarr and return the pyramid and loader -const getImageUrls = async (omeZarrUrl) => { - const omeZarrRoot = zarrRoot(ZipFileStore.fromUrl(omeZarrUrl)); +export { openOmePyramid } from './zarrPyramid'; - const { data } = await loadOmeZarr(omeZarrRoot); - const base = data[0]; +/** + * Decode one RGB histology tile from an already-opened pyramid for the given + * viewport, returning a canvas handed to Vega via registerDrawable (no PNG + * round-trip — see loadSegmentationOverlay.patchResourceLoader). + * + * @param {object} pyramid result of openOmePyramid + * @param {object} viewport { xMin, xMax, yMin, yMax, outputWidth, outputHeight } + * @returns {{ imageUrl, imageWidth, imageHeight, imageExtent, level } | null} + * imageWidth/imageHeight are always the level-0 full dims (the Vega scale + * domains); imageExtent is the data-space rectangle this tile covers; level is + * the chosen pyramid index (0 = full resolution). + */ +export const renderImageTile = async (pyramid, viewport) => { + try { + const { + levels, axesMetadata, fullW, fullH, + } = pyramid; + const region = resolvePyramidRegion(levels, fullW, fullH, viewport); + if (!region) return null; - const imageUrl = await imageDataToUrl(base); - return imageUrl; -}; + const { + level, shape, ndim, x0, x1, y0, y1, regionW, regionH, extent, + } = region; + const { arr } = levels[level]; + + // ── Find channel axis from metadata, fall back to ndim-3 ──────────────── + let channelAxisIdx = ndim - 3; + if (axesMetadata) { + const cIdx = axesMetadata.findIndex((a) => a.type === 'channel' || a.name === 'c'); + if (cIdx >= 0) channelAxisIdx = cIdx; + } + + // ── Read RGB channels ─────────────────────────────────────────────────── + const channels = await Promise.all([0, 1, 2].map(async (c) => { + const selection = shape.map((_, dimIdx) => { + if (dimIdx === channelAxisIdx) return c; + if (dimIdx === ndim - 2) return slice(y0, y1); + if (dimIdx === ndim - 1) return slice(x0, x1); + return 0; + }); + const ndArray = await get(arr, selection); + return ndArray.data; + })); + + // ── Build RGBA canvas ───────────────────────────────────────────────────── + const canvas = document.createElement('canvas'); + canvas.width = regionW; + canvas.height = regionH; + const ctx = canvas.getContext('2d'); + const imgData = ctx.createImageData(regionW, regionH); + const px = imgData.data; // Uint8ClampedArray — clamps to [0,255] automatically -const imageDataToUrl = async (base) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); + const [rData, gData, bData] = channels; + for (let i = 0; i < regionW * regionH; i += 1) { + // Assign directly without normalization. Uint8ClampedArray clamps any value + // outside [0,255] automatically, so uint8 (0–255) and uint16 data with values + // in the 0–255 range both render correctly (matches the original behaviour). + px[i * 4] = rData[i]; + px[i * 4 + 1] = gData[i]; + px[i * 4 + 2] = bData[i]; + px[i * 4 + 3] = 255; + } - // Extract dimensions from the first channel - const { width, height } = await base.getRaster({ selection: { c: 0, x: 0, y: 0 } }); + ctx.putImageData(imgData, 0, 0); - canvas.width = width; - canvas.height = height; + return { + imageUrl: registerDrawable(canvas), + imageWidth: fullW, // always level-0 full dims for Vega scale domains + imageHeight: fullH, + imageExtent: extent, + level, + }; + } catch (e) { + console.error('[renderImageTile]', e); + return null; + } +}; - const rgbaData = new Uint8ClampedArray(width * height * 4); +/** + * Read one histology tile at an EXPLICIT level/pixel window (from tilesForViewport) + * — used by the viewport tile streamer. Returns a canvas handed to Vega via + * registerDrawable plus the tile's data-space extent. + * + * @param {object} pyramid result of openOmePyramid + * @param {object} tile { level, x0, x1, y0, y1, extent } from tilesForViewport + * @returns {{ url, extent } | null} + */ +export const readImageTile = async (pyramid, tile) => { + try { + const { levels, axesMetadata } = pyramid; + const { + level, x0, x1, y0, y1, extent, + } = tile; + const { arr, shape } = levels[level]; + const ndim = shape.length; - // Process each RGB channel - await Promise.all([0, 1, 2].map(async (c) => { - const selection = { c, x: 0, y: 0 }; - const { data: pixelData } = await base.getRaster({ selection }); + let channelAxisIdx = ndim - 3; + if (axesMetadata) { + const cIdx = axesMetadata.findIndex((a) => a.type === 'channel' || a.name === 'c'); + if (cIdx >= 0) channelAxisIdx = cIdx; + } - pixelData.forEach((value, i) => { - rgbaData[i * 4 + c] = value; - }); - })); + const regionW = x1 - x0; + const regionH = y1 - y0; - // Set alpha channel to fully opaque - rgbaData.forEach((_, i) => { - if ((i + 1) % 4 === 0) { - rgbaData[i] = 255; + const channels = await Promise.all([0, 1, 2].map(async (c) => { + const selection = shape.map((_, dimIdx) => { + if (dimIdx === channelAxisIdx) return c; + if (dimIdx === ndim - 2) return slice(y0, y1); + if (dimIdx === ndim - 1) return slice(x0, x1); + return 0; + }); + const ndArray = await get(arr, selection); + return ndArray.data; + })); + + const canvas = document.createElement('canvas'); + canvas.width = regionW; + canvas.height = regionH; + const ctx = canvas.getContext('2d'); + const imgData = ctx.createImageData(regionW, regionH); + const px = imgData.data; + const [rData, gData, bData] = channels; + for (let i = 0; i < regionW * regionH; i += 1) { + px[i * 4] = rData[i]; + px[i * 4 + 1] = gData[i]; + px[i * 4 + 2] = bData[i]; + px[i * 4 + 3] = 255; } - }); + ctx.putImageData(imgData, 0, 0); - const imageDataObject = new ImageData(rgbaData, width, height); - ctx.putImageData(imageDataObject, 0, 0); + return { url: registerDrawable(canvas), extent }; + } catch (e) { + console.error('[readImageTile]', e); + return null; + } +}; - const imageUrl = canvas.toDataURL(); - return { imageUrl, imageWidth: width, imageHeight: height }; +// Open + render in one call (one-off full-extent loads). Prefer +// openOmePyramid + renderImageTile when issuing many viewport tiles for one slide. +const getImageUrls = async (omeZarrUrl, viewport) => { + try { + const pyramid = await openOmePyramid(omeZarrUrl); + return await renderImageTile(pyramid, viewport); + } catch (e) { + console.error('[getImageUrls]', e); + return null; + } }; export default getImageUrls; diff --git a/src/components/plots/loadSegmentationOverlay.js b/src/components/plots/loadSegmentationOverlay.js new file mode 100644 index 0000000000..38a7093cca --- /dev/null +++ b/src/components/plots/loadSegmentationOverlay.js @@ -0,0 +1,280 @@ +import { get, slice } from 'zarrita'; +import { ResourceLoader } from 'vega'; +import { resolvePyramidRegion } from './zarrPyramid'; + +export const parseHexColor = (hex) => { + const clean = (hex ?? '').replace('#', ''); + if (clean.length < 6) return [128, 128, 128]; + return [ + parseInt(clean.slice(0, 2), 16), + parseInt(clean.slice(2, 4), 16), + parseInt(clean.slice(4, 6), 16), + ]; +}; + +// Returns true if pixel (row, col) labelled `v` lies on its cell's boundary +// (4-connected: differs from at least one neighbour). Background is label 0. +const isCellEdge = (flatData, row, col, regionW, regionH, v) => { + const top = row > 0 ? flatData[(row - 1) * regionW + col] : 0; + const bottom = row < regionH - 1 ? flatData[(row + 1) * regionW + col] : 0; + const left = col > 0 ? flatData[row * regionW + (col - 1)] : 0; + const right = col < regionW - 1 ? flatData[row * regionW + (col + 1)] : 0; + return top !== v || bottom !== v || left !== v || right !== v; +}; + +/** + * Decode one segmentation-label tile from an already-opened pyramid for the given + * data-space viewport. Mirrors renderImageTile's level/region selection (shared + * resolvePyramidRegion) so the segmentation tile aligns pixel-for-pixel with the + * histology tile at the same viewport. Coarser pyramid levels are + * nearest-neighbour downsamples (per the OME-Zarr label convention), so label + * values — hence cell IDs — are preserved at every level. + * + * @param {object} pyramid result of openOmePyramid (segmentation zarr) + * @param {object} viewport { xMin, xMax, yMin, yMax, outputWidth, outputHeight } + * @returns {{ flatData, regionW, regionH, extent, level } | null} + * extent is the data-space rectangle this tile covers. + */ +export const decodeSegmentationRegion = async (pyramid, viewport) => { + try { + const { + levels, fullW, fullH, + } = pyramid; + const region = resolvePyramidRegion(levels, fullW, fullH, viewport); + if (!region) return null; + + const { + level, shape, ndim, x0, x1, y0, y1, regionW, regionH, extent, + } = region; + const { arr } = levels[level]; + + // Label arrays have no channel axis: pin every leading dim to 0, window the + // last two (y, x). + const selection = shape.map((_, dimIdx) => { + if (dimIdx === ndim - 2) return slice(y0, y1); + if (dimIdx === ndim - 1) return slice(x0, x1); + return 0; + }); + const ndArray = await get(arr, selection); + + return { + flatData: ndArray.data, + regionW, + regionH, + extent, + level, + }; + } catch (e) { + console.error('[decodeSegmentationRegion]', e); + return null; + } +}; + +/** + * Read one segmentation-label tile at an EXPLICIT level/pixel window (from + * tilesForViewport) — used by the viewport tile streamer. Coarser levels are + * nearest-neighbour label downsamples, so cell IDs are preserved at every level. + * + * A 1px HALO is read around the tile (clamped at the image edge) so outline + * edge-detection has the true neighbours across tile boundaries — otherwise every + * tile seam would be drawn as a fake cell outline. Only the inner tile area is + * rendered (see colorSegmentationOverlay's inner* offsets). + * + * @param {object} pyramid result of openOmePyramid (segmentation zarr) + * @param {object} tile { level, x0, x1, y0, y1, extent } from tilesForViewport + * @returns {{ flatData, regionW, regionH, innerX, innerY, innerW, innerH, extent } | null} + */ +export const readSegTile = async (pyramid, tile) => { + try { + const { levels } = pyramid; + const { + level, x0, x1, y0, y1, extent, + } = tile; + const { arr, shape } = levels[level]; + const ndim = shape.length; + const lw = shape[ndim - 1]; + const lh = shape[ndim - 2]; + + const hx0 = Math.max(0, x0 - 1); + const hx1 = Math.min(lw, x1 + 1); + const hy0 = Math.max(0, y0 - 1); + const hy1 = Math.min(lh, y1 + 1); + + const selection = shape.map((_, dimIdx) => { + if (dimIdx === ndim - 2) return slice(hy0, hy1); + if (dimIdx === ndim - 1) return slice(hx0, hx1); + return 0; + }); + const ndArray = await get(arr, selection); + + return { + flatData: ndArray.data, + regionW: hx1 - hx0, // haloed dims + regionH: hy1 - hy0, + innerX: x0 - hx0, // inner tile offset within the halo (0 or 1) + innerY: y0 - hy0, + innerW: x1 - x0, // rendered tile dims + innerH: y1 - y0, + extent, + }; + } catch (e) { + console.error('[readSegTile]', e); + return null; + } +}; + +// ── Drawable registry ────────────────────────────────────────────────────── +// Registry of drawables (canvas / image) addressed by a synthetic `seg-overlay://` +// URL, so Vega can paint them directly — see patchResourceLoader. Used for BOTH the +// coloured segmentation overlay tiles and the histology tiles. +const drawableRegistry = new Map(); +let drawableCounter = 0; + +// persistent ImageData buffer per reused canvas (see colorSegmentationOverlay) +const imageDataCache = new WeakMap(); + +/** + * Register a drawable (canvas/image) and return a synthetic URL token. Handing the + * drawable to Vega this way skips the heavy PNG round-trip (toDataURL on our side + + * Vega re-decoding the PNG into an on every view rebuild) — the only cost + * left is a GPU drawImage(). + */ +export const registerDrawable = (drawable) => { + drawableCounter += 1; + const token = `seg-overlay://${drawableCounter}`; + drawableRegistry.set(token, drawable); + return token; +}; + +const getDrawable = (token) => drawableRegistry.get(token); + +// Release a registered drawable once Vega has moved on (e.g. a superseded tile), +// so the registry doesn't accumulate stale canvases across recolours/zoom. +export const releaseOverlay = (token) => { + if (token) drawableRegistry.delete(token); +}; + +/* eslint-disable no-underscore-dangle */ // reaching into Vega ResourceLoader internals +// Patch Vega's ResourceLoader ONCE at module load so our `seg-overlay://` tokens +// resolve to the live canvas/image for ALL views — crucially including the very +// first render (a per-view onNewView patch would miss URLs already baked into the +// spec). Routed through the loader's pending counter so Vega's "redraw once the +// image is ready" machinery still fires. Any non-token URL falls through to the +// original loader untouched. +let resourceLoaderPatched = false; +const patchResourceLoader = () => { + if (resourceLoaderPatched) return; + const proto = ResourceLoader.prototype; + const originalLoadImage = proto.loadImage; + proto.loadImage = function loadImage(uri) { + const drawable = getDrawable(uri); + if (!drawable) return originalLoadImage.call(this, uri); + this._pending += 1; + return Promise.resolve().then(() => { + this._pending -= 1; + return drawable; + }); + }; + resourceLoaderPatched = true; +}; +patchResourceLoader(); +/* eslint-enable no-underscore-dangle */ + +/** + * Paint a coloured overlay from a decoded segmentation tile onto a canvas and hand + * that canvas to Vega (via the registry above). Cheap (no network/zarr, no PNG + * encode), so it can run on every colour/threshold change and per viewport tile. + * + * @param {object} decoded result of decodeSegmentationRegion + * @param {Map} cellColorMap 0-indexed cell ID → colour. + * A 4th element overrides the global opacity for that cell (0–255 alpha). + * @param {object} options { opacity: 0–1, outline: boolean, canvas?: HTMLCanvasElement } + * Pass a persistent `canvas` (one per layer) to avoid re-allocating the canvas and + * its pixel buffer on every recolour. The fill pass writes every pixel's alpha, so + * reusing the buffer needs no explicit clear. + * @returns {{ overlayUrl, overlayExtent } | null} + */ +/** + * Pure pixel-painting for a decoded segmentation tile — writes RGBA into `px` + * (a Uint8ClampedArray sized innerW*innerH*4). Extracted from colorSegmentation + * Overlay so the colour/visibility/outline logic is unit-testable without a canvas. + * + * Cells absent from `cellColorMap` (background, or filtered out in a previous step) + * are left transparent, and outlines are drawn only for cells in the map — matching + * the Data Exploration segmentation layer (which discards un-coloured cells) rather + * than showing a default grey. + */ +/* eslint-disable no-param-reassign */ // writing RGBA into the caller's pixel buffer +export const paintSegOverlayPixels = (px, decoded, cellColorMap, options = {}) => { + const { opacity = 0.7, outline = false } = options; + const fillAlpha = Math.round(opacity * 255); + const { flatData, regionW, regionH } = decoded; + // The decoded data may carry a 1px halo for edge-detection; render only the inner + // tile area and sample the (possibly haloed) source via these offsets. Defaults + // make this a no-op for un-haloed full-region decodes. + const innerX = decoded.innerX ?? 0; + const innerY = decoded.innerY ?? 0; + const innerW = decoded.innerW ?? regionW; + const innerH = decoded.innerH ?? regionH; + + // ── Fill pass (over the inner tile, sampling the haloed source) ───────────── + for (let r = 0; r < innerH; r += 1) { + for (let c = 0; c < innerW; c += 1) { + const v = flatData[(r + innerY) * regionW + (c + innerX)]; + const b = (r * innerW + c) * 4; + const color = v === 0 ? null : cellColorMap.get(v - 1); // bitmask is 1-indexed + if (color) { + [px[b], px[b + 1], px[b + 2]] = color; + // a 4th element overrides the global opacity for this cell + px[b + 3] = color.length >= 4 ? color[3] : fillAlpha; + } else { + px[b + 3] = 0; // background or filtered-out cell → hidden + } + } + } + + // ── Outline pass — edge-detect in haloed coords so tile seams aren't drawn ─── + if (outline) { + for (let r = 0; r < innerH; r += 1) { + for (let c = 0; c < innerW; c += 1) { + const hr = r + innerY; + const hc = c + innerX; + const v = flatData[hr * regionW + hc]; + if (v !== 0 && cellColorMap.has(v - 1) + && isCellEdge(flatData, hr, hc, regionW, regionH, v)) { + px[(r * innerW + c) * 4 + 3] = 255; + } + } + } + } +}; +/* eslint-enable no-param-reassign */ + +export const colorSegmentationOverlay = (decoded, cellColorMap, options = {}) => { + if (!decoded) return null; + const { canvas: reuseCanvas = null } = options; + const { regionW, regionH, extent } = decoded; + const innerW = decoded.innerW ?? regionW; + const innerH = decoded.innerH ?? regionH; + + const canvas = reuseCanvas || document.createElement('canvas'); + if (canvas.width !== innerW) canvas.width = innerW; + if (canvas.height !== innerH) canvas.height = innerH; + const ctx = canvas.getContext('2d'); + + // reuse one ImageData buffer per canvas — re-allocating it each recolour churns + // the GC during long slider drags + let imgData = imageDataCache.get(canvas); + if (!imgData || imgData.width !== innerW || imgData.height !== innerH) { + imgData = ctx.createImageData(innerW, innerH); + imageDataCache.set(canvas, imgData); + } + + paintSegOverlayPixels(imgData.data, decoded, cellColorMap, options); + + ctx.putImageData(imgData, 0, 0); + + // hand the canvas itself to Vega (resolved by patchResourceLoader) rather than + // PNG-encoding it — no toDataURL, no re-decode, just a GPU drawImage(canvas) + return { overlayUrl: registerDrawable(canvas), overlayExtent: extent }; +}; diff --git a/src/components/plots/spatialTileCache.js b/src/components/plots/spatialTileCache.js new file mode 100644 index 0000000000..35a94a52db --- /dev/null +++ b/src/components/plots/spatialTileCache.js @@ -0,0 +1,137 @@ +import { readImageTile } from './getImageUrls'; +import { readSegTile, releaseOverlay } from './loadSegmentationOverlay'; + +// Cross-instance caches of streamed spatial TILES, keyed by a stable +// experiment+sample key + pyramid coordinates (level/tx/ty) — NOT the signed URL, +// which changes per request. So a tile fetched/decoded once is reused across every +// spatial plot and across remounts (no redraw, no flash). Tissue tiles cache the +// rendered canvas (shared, read-only); segmentation tiles cache the decoded labels +// (the per-cell colouring is applied per-plot by useSpatialStream). +// +// Both caches are LRU-capped by tile count; the visible set at any zoom is small +// (tens of tiles), so the cap mostly bounds history, not the working set. +const TILE_CACHE_MAX = 256; + +// ── Global request scheduler ──────────────────────────────────────────────── +// Caps how many tiles decode concurrently (across ALL spatial plots) and — like +// viv's request scheduler — drops requests that are no longer wanted BEFORE they +// start, so sweeping through zoom levels on a fast zoom doesn't pile up decodes of +// tiles you've already zoomed past. Each job carries an isWanted() predicate (re- +// checked at start time, reading the caller's live "currently-visible" set) and a +// priority (higher first, e.g. target level over coarse fallback). +const MAX_CONCURRENT_TILE_LOADS = 6; +let activeLoads = 0; +const pendingJobs = []; + +const runJob = (job) => { + activeLoads += 1; + Promise.resolve() + .then(() => job.run()) + .then((v) => job.resolve(v), () => job.resolve(null)) + .finally(() => { activeLoads -= 1; pump(); }); // eslint-disable-line no-use-before-define +}; + +const pump = () => { + if (pendingJobs.length > 1) pendingJobs.sort((a, b) => b.priority - a.priority); + while (activeLoads < MAX_CONCURRENT_TILE_LOADS && pendingJobs.length) { + const job = pendingJobs.shift(); + if (job.isWanted && !job.isWanted()) { + job.resolve(null); // superseded before it started → skip (caller may re-request) + } else { + runJob(job); + } + } +}; + +const schedule = (run, { isWanted = null, priority = 0 } = {}) => new Promise((resolve) => { + pendingJobs.push({ + run, isWanted, priority, resolve, + }); + pump(); +}); + +const tileKey = (sampleKey, tile) => `${sampleKey}:${tile.level}:${tile.tx}:${tile.ty}`; + +// in-flight promise dedup + resolved map for synchronous peek +const tissuePromises = new Map(); +const tissueResolved = new Map(); +const segPromises = new Map(); +const segResolved = new Map(); + +const touch = (map, key) => { + // refresh LRU position + if (map.has(key)) { + const v = map.get(key); + map.delete(key); + map.set(key, v); + } +}; + +const evict = (resolved, promises, releaseFn) => { + while (resolved.size > TILE_CACHE_MAX) { + const oldest = resolved.keys().next().value; + const entry = resolved.get(oldest); + resolved.delete(oldest); + promises.delete(oldest); + if (releaseFn && entry) releaseFn(entry); + } +}; + +export const peekTissueTile = (sampleKey, tile) => { + const key = tileKey(sampleKey, tile); + if (!tissueResolved.has(key)) return null; + touch(tissueResolved, key); + return tissueResolved.get(key); +}; + +export const peekSegTile = (sampleKey, tile) => { + const key = tileKey(sampleKey, tile); + if (!segResolved.has(key)) return null; + touch(segResolved, key); + return segResolved.get(key); +}; + +/** + * Rendered histology tile { url, extent } for one pyramid tile, cached by key. + * The decode is gated by the global scheduler; a value of null means it failed or + * was skipped (superseded before starting) — callers should treat that as "retry + * later" and not cache it. + * @param {object} opts { isWanted, priority } for the scheduler + * @returns {Promise<{ url, extent } | null>} + */ +export const loadTissueTile = (pyramid, sampleKey, tile, opts = {}) => { + const key = tileKey(sampleKey, tile); + if (!tissuePromises.has(key)) { + const promise = schedule(() => readImageTile(pyramid, tile), opts); + promise.catch(() => tissuePromises.delete(key)); + promise.then((value) => { + if (!value) { tissuePromises.delete(key); return; } // skipped/failed → re-requestable + tissueResolved.set(key, value); + evict(tissueResolved, tissuePromises, (e) => releaseOverlay(e.url)); + }); + tissuePromises.set(key, promise); + } + return tissuePromises.get(key); +}; + +/** + * Decoded segmentation-label tile { flatData, regionW, regionH, extent } for one + * pyramid tile, cached by key. Colouring is applied per-plot. Scheduler-gated like + * loadTissueTile (null = skipped/failed → re-requestable). + * @param {object} opts { isWanted, priority } for the scheduler + * @returns {Promise<{ flatData, regionW, regionH, extent } | null>} + */ +export const loadSegTile = (pyramid, sampleKey, tile, opts = {}) => { + const key = tileKey(sampleKey, tile); + if (!segPromises.has(key)) { + const promise = schedule(() => readSegTile(pyramid, tile), opts); + promise.catch(() => segPromises.delete(key)); + promise.then((value) => { + if (!value) { segPromises.delete(key); return; } + segResolved.set(key, value); + evict(segResolved, segPromises, null); + }); + segPromises.set(key, promise); + } + return segPromises.get(key); +}; diff --git a/src/components/plots/styling/AxesDesign.jsx b/src/components/plots/styling/AxesDesign.jsx index 8441ef9b68..be775f4b9f 100644 --- a/src/components/plots/styling/AxesDesign.jsx +++ b/src/components/plots/styling/AxesDesign.jsx @@ -8,7 +8,10 @@ import DebouncedSlider from './DebouncedSlider'; import DebouncedInput from './DebouncedInput'; const AxesDesign = (props) => { - const { onUpdate, config, showAxisLabelsToggle = true, embeddingMethod, defaultXAxisText: propDefaultX = '', defaultYAxisText: propDefaultY = '' } = props; + const { + onUpdate, config, showAxisLabelsToggle = true, showOffset = true, + embeddingMethod, defaultXAxisText: propDefaultX = '', defaultYAxisText: propDefaultY = '', + } = props; // Calculate default values based on embedding method (uppercase, no space) // Or use provided props if embeddingMethod is not available @@ -124,17 +127,19 @@ const AxesDesign = (props) => { /> - - - + {showOffset && ( + + + + )} { + const { config, onUpdate } = props; + + const hideXRange = typeof config.axesRanges.xAxisAuto === 'undefined'; + const hideYRange = typeof config.axesRanges.yAxisAuto === 'undefined'; + + const [newConfig, setNewConfig] = useState(config.axesRanges); + + // if the plot changes, update the config + useEffect(() => { + setNewConfig(config.axesRanges); + }, [config]); + + const disableSave = () => { + if (_.isEqual(config.axesRanges, newConfig)) return true; + if (newConfig.xMin > newConfig.xMax) return true; + if (newConfig.yMin > newConfig.yMax) return true; + return false; + }; + + return ( + + + + + + + + + + ); +}; + +AxesRangesDesign.propTypes = { + config: PropTypes.object.isRequired, + onUpdate: PropTypes.func.isRequired, +}; + +export default AxesRangesDesign; diff --git a/src/components/plots/styling/ColourReverse.jsx b/src/components/plots/styling/ColourReverse.jsx new file mode 100644 index 0000000000..584eb78f93 --- /dev/null +++ b/src/components/plots/styling/ColourReverse.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Form, + Radio, +} from 'antd'; + +// Toggle the direction of the (continuous) colour scale. Bound to +// config.colour.reverseCbar — the same flag the overlay colouring and the Vega +// legend both read (see generateSpatialFeatureSpec / SpatialOutlierFilterPlot), so +// flipping it reverses both together. Defaults to whatever the plot config already +// has, i.e. the current direction. +const ColourReverse = (props) => { + const { onUpdate, config } = props; + + return ( +
+

Colour Scale Direction:

+ + onUpdate({ colour: { reverseCbar: e.target.value } })} + value={Boolean(config.colour.reverseCbar)} + > + Standard + Reversed + + +
+ ); +}; + +ColourReverse.propTypes = { + onUpdate: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, +}; + +export default ColourReverse; diff --git a/src/components/plots/styling/MultiViewGenesEditor.jsx b/src/components/plots/styling/MultiViewGenesEditor.jsx index 5aa177b72f..2cbf261fb3 100644 --- a/src/components/plots/styling/MultiViewGenesEditor.jsx +++ b/src/components/plots/styling/MultiViewGenesEditor.jsx @@ -55,7 +55,9 @@ const MultiViewGenesEditor = (props) => { return uuids.map((uuid, index) => { const row = Math.floor(index / localNCols) + 1; const col = (index % localNCols) + 1; - return { label: `${row}.${col} ${shownGenes[index]}`, value: uuid }; + const gene = shownGenes[index]; + // omit the gene until it has loaded so the option doesn't read "1.1 undefined" + return { label: gene ? `${row}.${col} ${gene}` : `${row}.${col}`, value: uuid }; }); }; @@ -66,7 +68,8 @@ const MultiViewGenesEditor = (props) => { useEffect(() => { if (!multiViewConfig) return; - if ((!selectedPlotUuid && multiViewPlotUuids.length) || !multiViewPlotUuids.includes(selectedPlotUuid)) { + if ((!selectedPlotUuid && multiViewPlotUuids.length) + || !multiViewPlotUuids.includes(selectedPlotUuid)) { setSelectedPlotUuid(multiViewPlotUuids[0]); } if (localNRows !== multiViewConfig.nrows) { @@ -82,7 +85,7 @@ const MultiViewGenesEditor = (props) => { if (!multiViewConfig || !localNRows || !localNCols) return; if (!_.isEqual((options.map((option) => option?.value)), multiViewConfig.plotUuids) - || !options.every((option, i) => option?.label.includes(shownGenes[i]))) { + || !options.every((option, i) => option?.label.includes(shownGenes[i] ?? ''))) { setOptions(renderUuidOptions(multiViewConfig.plotUuids)); } }, [multiViewConfig, shownGenes, localNRows, localNCols]); diff --git a/src/components/plots/styling/PlotStyling.jsx b/src/components/plots/styling/PlotStyling.jsx index b765c8e38c..f1a8481429 100644 --- a/src/components/plots/styling/PlotStyling.jsx +++ b/src/components/plots/styling/PlotStyling.jsx @@ -14,11 +14,14 @@ import LabelsDesign from './LabelsDesign'; import DimensionsRangeEditor from './DimensionsRangeEditor'; import AxesDesign from './AxesDesign'; import AxesWithRangesDesign from './AxesWithRangesDesign'; +import AxesRangesDesign from './AxesRangesDesign'; import PointDesign from './PointDesign'; import ColourbarDesign from './ColourbarDesign'; import ColourInversion from './ColourInversion'; +import ColourReverse from './ColourReverse'; import ExpressionValuesType from './ExpressionValuesType'; import ExpressionValuesCapping from './ExpressionValuesCapping'; +import ShowImageToggle from './ShowImageToggle'; import ViolinMarkersEditor from './violin/MarkersEditor'; @@ -39,11 +42,14 @@ const PlotStyling = (props) => { font: (attr) => , axes: (attr) => , axesWithRanges: (attr) => , + axesRanges: (attr) => , colourScheme: (attr) => , colourInversion: (attr) => , + colourReverse: (attr) => , expressionValuesType: (attr) => , expressionValuesCapping: (attr) => , markers: (attr) => , + showImage: (attr) => , legend: (attr) => , labels: (attr) => , violinMarkers: (attr) => , diff --git a/src/components/plots/styling/PointDesign.jsx b/src/components/plots/styling/PointDesign.jsx index b07fcf8523..d5b33c86d2 100644 --- a/src/components/plots/styling/PointDesign.jsx +++ b/src/components/plots/styling/PointDesign.jsx @@ -7,34 +7,47 @@ import { import DebouncedSlider from './DebouncedSlider'; const PointDesign = (props) => { - const { onUpdate, config, showShapeType } = props; + const { + onUpdate, config, showShapeType, spatial, + pointSizeMin, pointSizeMax, pointSizeStep, + } = props; + + // For spatial plots the marks are segmentation polygons, not points: hide the + // point size/shape controls and relabel the remaining ones accordingly. + const styleHeader = spatial ? 'Segmentation Style' : 'Point Style'; return (
-

Point Style:

- - - +

{`${styleHeader}:`}

+ { + !spatial + && ( + + + + ) + } { config.marker.showOpacity && ( { ) } { - showShapeType + showShapeType && !spatial && ( <>

Point Shape:

- onUpdate({ marker: { shape: e.target.value } })} value={config.marker.shape}> + onUpdate({ marker: { shape: e.target.value } })} + value={config.marker.shape} + > Circle Diamond + + ) + } + { + (showShapeType || spatial) + && ( + <>

Toggle Outline:

- onUpdate({ marker: { outline: e.target.value } })} value={config.marker.outline}> + onUpdate({ marker: { outline: e.target.value } })} + value={config.marker.outline} + > Show Hide @@ -77,10 +103,18 @@ PointDesign.propTypes = { config: PropTypes.object.isRequired, onUpdate: PropTypes.func.isRequired, showShapeType: PropTypes.bool, + spatial: PropTypes.bool, + pointSizeMin: PropTypes.number, + pointSizeMax: PropTypes.number, + pointSizeStep: PropTypes.number, }; PointDesign.defaultProps = { showShapeType: true, + spatial: false, + pointSizeMin: 1, + pointSizeMax: 100, + pointSizeStep: 1, }; export default PointDesign; diff --git a/src/components/plots/styling/ShowImageToggle.jsx b/src/components/plots/styling/ShowImageToggle.jsx new file mode 100644 index 0000000000..20d3c04d69 --- /dev/null +++ b/src/components/plots/styling/ShowImageToggle.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form, Radio } from 'antd'; + +// Toggles whether the tissue (histology) image is shown beneath the +// segmentation overlay in spatial plots (writes config.showImage). +const ShowImageToggle = (props) => { + const { config, onUpdate } = props; + + return ( + + onUpdate({ showImage: e.target.value })} + > + Show + Hide + + + ); +}; + +ShowImageToggle.propTypes = { + config: PropTypes.object.isRequired, + onUpdate: PropTypes.func.isRequired, +}; + +export default ShowImageToggle; diff --git a/src/components/plots/styling/embedding-continuous/SelectData.jsx b/src/components/plots/styling/embedding-continuous/SelectData.jsx index da6b2d4c57..4d757561eb 100644 --- a/src/components/plots/styling/embedding-continuous/SelectData.jsx +++ b/src/components/plots/styling/embedding-continuous/SelectData.jsx @@ -8,7 +8,7 @@ import { Radio, } from 'antd'; -import { spatialPlotTypes, plotUuids } from 'utils/constants'; +import { spatialPlotTypes } from 'utils/constants'; import { metadataKeyToName } from 'utils/data-management/metadataUtils'; @@ -17,7 +17,7 @@ import InlineError from 'components/InlineError'; const { Option, OptGroup } = Select; const SelectData = (props) => { const { - onUpdate, config, cellSets, disabled, plotType, + onUpdate, config, cellSets, disabled, plotType, showImageToggle, } = props; const { @@ -55,12 +55,20 @@ const SelectData = (props) => { return ; } + // Default the dropdown to the sample that's actually shown. Spatial plots have + // no 'All' option and default config.selectedSample to null, so fall back to the + // first sample (which is what the plot itself renders by default). Computed AFTER + // the guards above so we never touch a null config (which threw and crashed the + // control panel on first render). + const firstSampleKey = getMetadataOptions(parents[0]?.value)?.[0]?.key; + const selectedValue = config.selectedSample || (isSpatial ? firstSampleKey : 'All'); + return ( <> -

Included Samples:

+

{isSpatial ? 'Selected sample:' : 'Included Samples:'}

- {isSpatial && ( + {isSpatial && showImageToggle && ( <>

Toggle Image:

@@ -102,11 +110,13 @@ SelectData.propTypes = { cellSets: PropTypes.object.isRequired, disabled: PropTypes.bool, plotType: PropTypes.string, + showImageToggle: PropTypes.bool, }; SelectData.defaultProps = { config: null, disabled: false, plotType: null, + showImageToggle: true, }; export default SelectData; diff --git a/src/components/plots/usePreventWheelScroll.js b/src/components/plots/usePreventWheelScroll.js new file mode 100644 index 0000000000..0d73936746 --- /dev/null +++ b/src/components/plots/usePreventWheelScroll.js @@ -0,0 +1,33 @@ +import { useRef, useCallback } from 'react'; + +/** + * Returns a callback ref to attach to the element wrapping a wheel-zoomable plot. + * It registers a NON-PASSIVE `wheel` listener that preventDefaults ONLY when the + * pointer is over the plot canvas (where the wheel zooms) — so zooming never falls + * through to scrolling the page, but wheeling over the surrounding (larger) wrapper + * still scrolls the page normally. Vega's own `wheel!` handler is registered + * passively in some browsers and lets the odd event escape, hence this guard. + * + * @param {boolean} enabled attach the guard (e.g. false for non-interactive minis) + */ +const usePreventWheelScroll = (enabled = true) => { + const cleanupRef = useRef(null); + + return useCallback((el) => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + if (el && enabled) { + const onWheel = (e) => { + if (e.target instanceof Element && e.target.closest('canvas')) { + e.preventDefault(); + } + }; + el.addEventListener('wheel', onWheel, { passive: false }); + cleanupRef.current = () => el.removeEventListener('wheel', onWheel); + } + }, [enabled]); +}; + +export default usePreventWheelScroll; diff --git a/src/components/plots/useSpatialStream.js b/src/components/plots/useSpatialStream.js new file mode 100644 index 0000000000..6e827df936 --- /dev/null +++ b/src/components/plots/useSpatialStream.js @@ -0,0 +1,458 @@ +import { + useState, useEffect, useRef, useCallback, +} from 'react'; + +import { openOmePyramid, pickLevel, tilesForViewport } from './zarrPyramid'; +import { colorSegmentationOverlay, releaseOverlay } from './loadSegmentationOverlay'; +import { + loadTissueTile, loadSegTile, peekTissueTile, peekSegTile, +} from './spatialTileCache'; + +// Tile edge length in level pixels. Smaller → more, cheaper requests; larger → fewer. +const TILE_SIZE = 512; + +const keyOf = (tile) => `${tile.level}:${tile.tx}:${tile.ty}`; +const intersects = (e, vp) => ( + e.xMax > vp.xMin && e.xMin < vp.xMax && e.yMax > vp.yMin && e.yMin < vp.yMax +); +const toRow = (url, extent) => ({ + url, x1: extent.xMin, x2: extent.xMax, y1: extent.yMin, y2: extent.yMax, +}); + +// thin wrappers over the pyramid helpers to keep call sites short +const levelFor = (p, reqViewport) => pickLevel(p.levels, p.fullW, p.fullH, reqViewport); +const tilesAt = (p, level, vp) => ( + tilesForViewport(p.levels, p.fullW, p.fullH, level, vp, TILE_SIZE) +); + +/** + * Viv-style viewport tile streaming for the spatial plots (SpatialFeaturePlot, + * SpatialCategoricalPlot, SpatialOutlierFilterPlot). + * + * The slide is rendered as a grid of pyramid tiles fed to Vega via the `data` prop + * (so updates never rebuild the view). As the view pans/zooms, the tiles covering + * the current viewport are streamed at the pyramid level that matches the on-screen + * resolution — the appropriate resolution is always visible: + * • TISSUE (opaque): every cached tile intersecting the viewport is drawn, + * coarse→fine, so finer tiles paint over coarser ones as they arrive — never a + * blank or stretched gap. + * • SEGMENTATION (semi-transparent): only the target level is drawn at rest (one + * tile per section → no blurry base bleeding through the per-cell alpha); while + * the target grid is still loading, the coarsest level is drawn underneath as a + * transient fallback. + * + * The caller drives this by calling `onViewportChange(xdom, ydom)` from the plot's + * `domUpdates` signal listener (and once after restoring a persisted zoom). Output + * interface matches the previous hook, so the plot components are unchanged. + */ +const useSpatialStream = ({ + experimentId, + sampleId, + omeZarrUrl, + segmentationUrl, // undefined = still probing, null = unavailable + plotWidth, + plotHeight, + showImage, + colorMap, // Map | null + colorKey, // string — changes when colorMap should be re-applied + opacity, // 0–1 + outline, // boolean +}) => { + const sampleKey = sampleId ? `${experimentId}-${sampleId}` : null; + + const [imageDims, setImageDims] = useState(null); + const [tissueRows, setTissueRows] = useState([]); + const [segRows, setSegRows] = useState([]); + // bumps whenever a probe resolves, to recompute the *Available booleans + const [, forceTick] = useState(0); + + // ── Latest-value refs read by the (throttled) streamer ────────────────────── + const colorMapRef = useRef(colorMap); colorMapRef.current = colorMap; + const optsRef = useRef({ opacity, outline }); optsRef.current = { opacity, outline }; + // identifies the rendered seg colouring — includes opacity/outline so changing + // those (not just the colour map) re-colours the cached tiles. + const colorKeyRef = useRef(); colorKeyRef.current = `${colorKey}:${opacity}:${outline}`; + const plotDimsRef = useRef({ plotWidth, plotHeight }); + plotDimsRef.current = { plotWidth, plotHeight }; + const showImageRef = useRef(showImage); showImageRef.current = showImage; + const sampleKeyRef = useRef(sampleKey); sampleKeyRef.current = sampleKey; + + // pyramid refs hold { key, pyramid } where key is the sampleKey the pyramid was + // opened for. They're resolved through histFor()/segFor(), which return the + // pyramid ONLY if it still matches the current sample — so a stream() that fires + // mid sample-switch (stale pyramid, new sampleKey) can't load the previous slide's + // tiles under the new sample's cache keys (which would persist as a wrong base). + const histPyramidRef = useRef(null); + const segPyramidRef = useRef(null); + const dimsRef = useRef(null); + const viewportRef = useRef(null); + + const histFor = useCallback(() => { + const h = histPyramidRef.current; + return h && h.key === sampleKeyRef.current ? h.pyramid : null; + }, []); + const segFor = useCallback(() => { + const s = segPyramidRef.current; + return s && s.key === sampleKeyRef.current ? s.pyramid : null; + }, []); + + // per-instance tile bookkeeping (cleared on sample change) + // knownTissue: key -> { tile, url } + // knownSeg: key -> { tile, decoded } + // segOverlays: key -> { tile, canvas, overlayUrl, extent, coloredKey } + const knownTissueRef = useRef(new Map()); + const knownSegRef = useRef(new Map()); + const segOverlaysRef = useRef(new Map()); + + // keys of the tiles currently wanted (this viewport), read by the scheduler's + // isWanted() so it drops requests for tiles we've since zoomed/panned past + const wantedTissueKeysRef = useRef(new Set()); + const wantedSegKeysRef = useRef(new Set()); + // rAF handles coalescing eager stream/publish to at most once per frame + const rafStreamRef = useRef(0); + const rafPublishRef = useRef(0); + + // ── Colour one decoded seg tile into a per-instance canvas (cached by colorKey) ─ + const colorSegTile = useCallback((key, tile, decoded) => { + const cmap = colorMapRef.current; + if (!cmap) return null; + const existing = segOverlaysRef.current.get(key); + if (existing && existing.coloredKey === colorKeyRef.current) return existing; + const canvas = existing?.canvas || (typeof document !== 'undefined' ? document.createElement('canvas') : null); + if (!canvas) return null; + const result = colorSegmentationOverlay(decoded, cmap, { ...optsRef.current, canvas }); + if (!result) return null; + if (existing) releaseOverlay(existing.overlayUrl); + const entry = { + tile, + canvas, + overlayUrl: result.overlayUrl, + extent: result.overlayExtent, + coloredKey: colorKeyRef.current, + }; + segOverlaysRef.current.set(key, entry); + return entry; + }, []); + + // ── Publish the rendered rows from whatever tiles are currently loaded ─────── + const publish = useCallback(() => { + const vp = viewportRef.current; + if (!vp) return; + + // TISSUE: every cached tile that still intersects the viewport, coarse→fine. + if (showImageRef.current) { + const rows = [...knownTissueRef.current.values()] + .filter((t) => intersects(t.tile.extent, vp)) + .sort((a, b) => b.tile.level - a.tile.level) + .map((t) => toRow(t.url, t.tile.extent)); + setTissueRows(rows); + } else { + setTissueRows([]); + } + + // SEGMENTATION: target level at rest; a single coarser fallback layer underneath + // only until the target grid is complete. + const segP = segFor(); + if (segP) { + const { plotWidth: ow, plotHeight: oh } = plotDimsRef.current; + const targetLevel = levelFor(segP, { ...vp, outputWidth: ow, outputHeight: oh }); + const coarsest = segP.levels.length - 1; + const targetTiles = tilesAt(segP, targetLevel, vp); + + const coloured = (t) => { + const e = segOverlaysRef.current.get(keyOf(t)); + return e && e.coloredKey === colorKeyRef.current ? e : null; + }; + // fully-coloured entries for a level's viewport tiles, or null if any are missing + const completeEntriesAt = (level) => { + const tiles = tilesAt(segP, level, vp); + if (tiles.length === 0) return null; + const entries = tiles.map(coloured).filter(Boolean); + return entries.length === tiles.length ? entries : null; + }; + + const targetEntries = targetTiles.map(coloured).filter(Boolean); + const complete = targetEntries.length === targetTiles.length && targetTiles.length > 0; + + let entries = targetEntries; + if (!complete) { + // Use the FINEST already-loaded coarser level as the fallback underlay rather + // than always the coarsest. While slow-zoom crosses a level threshold the new + // target isn't loaded yet; dropping straight to the coarsest (e.g. 16×) makes + // labels snap to a coarse grid and the overlay visibly jumps before the target + // corrects it. Reusing the previous (decent) level keeps it a single underlay + // (no extra alpha stacking) with only a small resolution drop — blur, not jump. + let fallbackEntries = null; + for (let lvl = targetLevel + 1; lvl <= coarsest; lvl += 1) { + fallbackEntries = completeEntriesAt(lvl); + if (fallbackEntries) break; + } + if (fallbackEntries) entries = [...fallbackEntries, ...targetEntries]; + } + // dedup + coarse→fine + const seen = new Set(); + const rows = entries + .sort((a, b) => b.tile.level - a.tile.level) + .filter((e) => { + const k = keyOf(e.tile); + if (seen.has(k)) return false; + seen.add(k); + return true; + }) + .map((e) => toRow(e.overlayUrl, e.extent)); + setSegRows(rows); + } else { + setSegRows([]); + } + }, [segFor]); + + // rAF-coalesced publish: many tiles can arrive in one frame, but the Vega data is + // rebuilt + setState at most once per frame. + const schedulePublish = useCallback(() => { + if (typeof requestAnimationFrame === 'undefined') { publish(); return; } + if (rafPublishRef.current) return; + rafPublishRef.current = requestAnimationFrame(() => { + rafPublishRef.current = 0; + publish(); + }); + }, [publish]); + + // ── Request the tiles needed for the current viewport; recolour on demand ──── + const streamCore = useCallback(() => { + const vp = viewportRef.current; + const dims = dimsRef.current; + if (!vp || !dims) return; + const { plotWidth: ow, plotHeight: oh } = plotDimsRef.current; + const reqViewport = { ...vp, outputWidth: ow, outputHeight: oh }; + + // tiles to load this pass: target level (priority 1) + coarsest fallback (0) + const tilesToLoad = (p) => { + const tl = levelFor(p, reqViewport); + const coarsest = p.levels.length - 1; + const target = tilesAt(p, tl, vp).map((tile) => ({ tile, priority: 1 })); + if (coarsest === tl) return target; + const fallback = tilesAt(p, coarsest, vp).map((tile) => ({ tile, priority: 0 })); + return [...target, ...fallback]; + }; + + const histP = histFor(); + const wantTissue = (showImageRef.current && histP) ? tilesToLoad(histP) : []; + const segP = segFor(); + const wantSeg = segP ? tilesToLoad(segP) : []; + + // expose the live "wanted" sets so the scheduler can drop requests for tiles we + // zoom/pan past before they start decoding + wantedTissueKeysRef.current = new Set(wantTissue.map(({ tile }) => keyOf(tile))); + wantedSegKeysRef.current = new Set(wantSeg.map(({ tile }) => keyOf(tile))); + + const myKey = sampleKeyRef.current; + + // Tissue: load missing canvases (shared cache, scheduler-gated) + wantTissue.forEach(({ tile, priority }) => { + const k = keyOf(tile); + if (knownTissueRef.current.has(k)) return; + knownTissueRef.current.set(k, { tile, url: null }); // reserve + loadTissueTile(histP, myKey, tile, { + priority, + isWanted: () => sampleKeyRef.current === myKey && wantedTissueKeysRef.current.has(k), + }).then((res) => { + if (sampleKeyRef.current !== myKey || !res) { + if (!res) knownTissueRef.current.delete(k); // skipped/failed → re-requestable + return; + } + knownTissueRef.current.set(k, { tile, url: res.url }); + schedulePublish(); + }); + }); + + // Seg: load missing decoded labels, then colour into a per-instance canvas + wantSeg.forEach(({ tile, priority }) => { + const k = keyOf(tile); + const cached = knownSegRef.current.get(k); + if (cached?.decoded) { + if (colorSegTile(k, tile, cached.decoded)) schedulePublish(); + return; + } + if (cached) return; // already in flight + knownSegRef.current.set(k, { tile, decoded: null }); + loadSegTile(segP, myKey, tile, { + priority, + isWanted: () => sampleKeyRef.current === myKey && wantedSegKeysRef.current.has(k), + }).then((decoded) => { + if (sampleKeyRef.current !== myKey || !decoded) { + if (!decoded) knownSegRef.current.delete(k); + return; + } + knownSegRef.current.set(k, { tile, decoded }); + colorSegTile(k, tile, decoded); + schedulePublish(); + }); + }); + + // recolour any already-loaded seg tiles whose colour is stale (colorKey change) + knownSegRef.current.forEach((entry, k) => { + if (entry.decoded) colorSegTile(k, entry.tile, entry.decoded); + }); + + // prune far-from-viewport tiles (keep visible + coarsest), release seg tokens + const margin = { + xMin: vp.xMin - (vp.xMax - vp.xMin), + xMax: vp.xMax + (vp.xMax - vp.xMin), + yMin: vp.yMin - (vp.yMax - vp.yMin), + yMax: vp.yMax + (vp.yMax - vp.yMin), + }; + const histCoarsest = histP ? histP.levels.length - 1 : -1; + knownTissueRef.current.forEach((entry, k) => { + if (entry.tile.level !== histCoarsest && !intersects(entry.tile.extent, margin)) { + knownTissueRef.current.delete(k); + } + }); + const segCoarsest = segP ? segP.levels.length - 1 : -1; + segOverlaysRef.current.forEach((entry, k) => { + if (entry.tile.level !== segCoarsest && !intersects(entry.tile.extent, margin)) { + releaseOverlay(entry.overlayUrl); + segOverlaysRef.current.delete(k); + knownSegRef.current.delete(k); + } + }); + + schedulePublish(); + }, [colorSegTile, schedulePublish, histFor, segFor]); + + // eager, frame-coalesced: recompute + request at most once per animation frame + // (like viv's per-frame tile selection), with no fixed debounce delay + const scheduleStream = useCallback(() => { + if (typeof requestAnimationFrame === 'undefined') { streamCore(); return; } + if (rafStreamRef.current) return; + rafStreamRef.current = requestAnimationFrame(() => { + rafStreamRef.current = 0; + streamCore(); + }); + }, [streamCore]); + + const onViewportChange = useCallback((xdom, ydom) => { + viewportRef.current = { + xMin: xdom[0], xMax: xdom[1], yMin: ydom[0], yMax: ydom[1], + }; + scheduleStream(); + }, [scheduleStream]); + + // ── Reset all per-instance tiles when the slide changes ───────────────────── + // Also drop the pyramid refs: they're re-set asynchronously by the open effects + // below, and if stream() runs in that gap with a STALE pyramid it would load the + // previous slide's tiles under the NEW sample's cache keys — so the correct reload + // then hits the cache and shows the old image. + useEffect(() => { + histPyramidRef.current = null; + segPyramidRef.current = null; + knownTissueRef.current = new Map(); + knownSegRef.current = new Map(); + segOverlaysRef.current.forEach((e) => releaseOverlay(e.overlayUrl)); + segOverlaysRef.current = new Map(); + setTissueRows([]); + setSegRows([]); + }, [sampleId]); + + // ── Open histology pyramid (dims) ─────────────────────────────────────────── + useEffect(() => { + if (!omeZarrUrl) return undefined; + let cancelled = false; + const myKey = sampleKeyRef.current; + openOmePyramid(omeZarrUrl).then((p) => { + if (cancelled || !p) return; + histPyramidRef.current = { key: myKey, pyramid: p }; + const dims = { imageWidth: p.fullW, imageHeight: p.fullH }; + dimsRef.current = dims; + setImageDims(dims); + if (!viewportRef.current) { + viewportRef.current = { + xMin: 0, xMax: p.fullW, yMin: 0, yMax: p.fullH, + }; + } + scheduleStream(); + }); + return () => { cancelled = true; }; + }, [omeZarrUrl, scheduleStream]); + + // ── Open segmentation pyramid ─────────────────────────────────────────────── + useEffect(() => { + if (!segmentationUrl) return undefined; + let cancelled = false; + const myKey = sampleKeyRef.current; + openOmePyramid(segmentationUrl).then((p) => { + if (cancelled || !p) return; + segPyramidRef.current = { key: myKey, pyramid: p }; + if (!dimsRef.current) { + const dims = { imageWidth: p.fullW, imageHeight: p.fullH }; + dimsRef.current = dims; + setImageDims(dims); + } + if (!viewportRef.current) { + viewportRef.current = { + xMin: 0, xMax: p.fullW, yMin: 0, yMax: p.fullH, + }; + } + forceTick((n) => n + 1); + scheduleStream(); + }); + return () => { cancelled = true; }; + }, [segmentationUrl, scheduleStream]); + + // ── Re-stream when colour / opacity / outline / plot size changes ──────────── + useEffect(() => { + scheduleStream(); + }, [colorKey, opacity, outline, plotWidth, plotHeight, showImage, scheduleStream]); + + // ── Seed from the shared cache on mount for a no-flash remount ─────────────── + useEffect(() => { + const segP = segFor(); + const histP = histFor(); + const vp = viewportRef.current; + if (!vp || !sampleKey) return; + const { plotWidth: ow, plotHeight: oh } = plotDimsRef.current; + const reqViewport = { ...vp, outputWidth: ow, outputHeight: oh }; + if (histP && showImage) { + tilesAt(histP, levelFor(histP, reqViewport), vp).forEach((tile) => { + const hit = peekTissueTile(sampleKey, tile); + if (hit) knownTissueRef.current.set(keyOf(tile), { tile, url: hit.url }); + }); + } + if (segP) { + tilesAt(segP, levelFor(segP, reqViewport), vp).forEach((tile) => { + const hit = peekSegTile(sampleKey, tile); + if (hit) { + knownSegRef.current.set(keyOf(tile), { tile, decoded: hit }); + colorSegTile(keyOf(tile), tile, hit); + } + }); + } + schedulePublish(); + // run once the pyramids/dims are first available + }, [imageDims, sampleKey, showImage, colorSegTile, schedulePublish, histFor, segFor]); + + // ── Cleanup on unmount ────────────────────────────────────────────────────── + useEffect(() => () => { + if (typeof cancelAnimationFrame !== 'undefined') { + if (rafStreamRef.current) cancelAnimationFrame(rafStreamRef.current); + if (rafPublishRef.current) cancelAnimationFrame(rafPublishRef.current); + } + segOverlaysRef.current.forEach((e) => releaseOverlay(e.overlayUrl)); + // tissue tile canvases are owned by the shared cache (LRU) — not released here + }, []); + + const segmentationsAvailable = !!segmentationUrl || !!segFor(); + const segProbeDone = segmentationUrl !== undefined || !!segFor(); + const ready = !!imageDims; + + return { + imageDims, + segmentationsAvailable, + segProbeDone, + tissueImageData: tissueRows, + segOverlayData: segRows, + onViewportChange, + ready, + }; +}; + +export default useSpatialStream; diff --git a/src/components/plots/zarrPyramid.js b/src/components/plots/zarrPyramid.js new file mode 100644 index 0000000000..e62418ad29 --- /dev/null +++ b/src/components/plots/zarrPyramid.js @@ -0,0 +1,286 @@ +import { + root as zarrRoot, open, +} from 'zarrita'; +import ZipFileStore from 'components/data-exploration/spatial/ZipFileStore'; + +// Generic OME-Zarr pyramid access shared by the histology tile renderer +// (getImageUrls) and the segmentation region decoder (loadSegmentationOverlay). +// Kept in its own module so neither of those imports the other (which would be a +// cycle). + +// ── Pyramid open, cached per signed URL ───────────────────────────────────── +// Opening reads only the multiscale metadata + each level's shape (no pixel +// data), so it's cheap; we still cache the opened arrays per URL so the base tile +// and every subsequent viewport tile reuse one open instead of re-reading +// metadata on each zoom. Histology and segmentation are different URLs → distinct +// cache entries. +// LRU-capped: keyed by the SIGNED url, which changes on every fetch, so without a +// cap a long session (each plot mount / sample switch re-signs the url) accumulates +// one zip store per url forever — and each store caches decompressed chunks as tiles +// stream, growing unbounded. Capping releases stores from no-longer-open slides. +const pyramidCache = new Map(); +const PYRAMID_CACHE_MAX = 8; + +// ── Per-level coordinate frame ────────────────────────────────────────────── +// Every tile is positioned in the plot from its data-space extent. To keep the +// coarse fallback tile and the finer target tile REGISTERED (no positional jump +// when a sharper tile streams in), all levels must map onto ONE coordinate frame +// (level-0 pixels). We express each level as an affine map +// fullPx = levelPx / pxPerFull + offsetFull (so levelPx = (fullPx - offset) * pxPerFull) +// and store pxPerFullX/Y + offsetFullX/Y per level. +// +// The trap we're avoiding: reconstructing the scale as levelWidth/fullWidth is +// WRONG whenever a level's dimensions were floor-rounded (e.g. 4001 → 250 at a 16× +// level gives 16.004, not 16). Across the slide that few-pixel drift is the +// positional shift you see when a sharp tile replaces a coarse one. We instead pin +// each level to the EXACT downsample factor. +// +// Our pyramids are OME-Zarr v0.3 (no `coordinateTransformations`), written by +// ome_zarr.writer.write_image with integer scale_factors [2,4,8,16] (skimage +// resize, which aligns pixel CENTRES — so an exact integer factor with offset 0 is +// correct; there is no half-pixel term). We recover those integer factors by +// rounding the ratio between CONSECUTIVE level dims (robust at coarse levels, where +// the level-0 ratio has drifted) and accumulating. (We don't read v0.4+ +// coordinateTransformations — the pipeline only writes v0.3.) +const widthOf = (lvl) => lvl.shape[lvl.shape.length - 1]; +const heightOf = (lvl) => lvl.shape[lvl.shape.length - 2]; + +function attachLevelTransforms(levels) { + // v0.3 / no transforms: infer exact integer factors from consecutive dims. + let cumX = 1; + let cumY = 1; + /* eslint-disable no-param-reassign */ + levels.forEach((lvl, i) => { + if (i > 0) { + cumX *= Math.max(1, Math.round(widthOf(levels[i - 1]) / widthOf(lvl))); + cumY *= Math.max(1, Math.round(heightOf(levels[i - 1]) / heightOf(lvl))); + } + lvl.pxPerFullX = 1 / cumX; + lvl.pxPerFullY = 1 / cumY; + lvl.offsetFullX = 0; + lvl.offsetFullY = 0; + }); + /* eslint-enable no-param-reassign */ +} + +// level pixels per level-0 pixel (`k`) and level-0-pixel offset (`b`) for a level, +// per axis. Uses the affine attached by attachLevelTransforms; falls back to the +// shape ratio with no offset — identical to the historical levelWidth/fullWidth math +// — only for synthetic levels passed straight to these pure helpers (unit tests), +// since openOmePyramid always attaches an affine for real pyramids. +const axisMapX = (lvl, fullW) => ({ + k: lvl.pxPerFullX ?? (lvl.shape[lvl.shape.length - 1] / fullW), + b: lvl.offsetFullX ?? 0, +}); +const axisMapY = (lvl, fullH) => ({ + k: lvl.pxPerFullY ?? (lvl.shape[lvl.shape.length - 2] / fullH), + b: lvl.offsetFullY ?? 0, +}); + +export const openOmePyramid = (omeZarrUrl) => { + if (pyramidCache.has(omeZarrUrl)) { + // refresh LRU position + const cached = pyramidCache.get(omeZarrUrl); + pyramidCache.delete(omeZarrUrl); + pyramidCache.set(omeZarrUrl, cached); + return cached; + } + + const promise = (async () => { + const store = ZipFileStore.fromUrl(omeZarrUrl); + const rootNode = zarrRoot(store); + + let datasets = [{ path: '0' }]; + let axesMetadata = null; + try { + const rootGroup = await open(rootNode, { kind: 'group' }); + const rootAttrs = await Promise.resolve(rootGroup.attrs); + datasets = rootAttrs?.multiscales?.[0]?.datasets || datasets; + axesMetadata = rootAttrs?.multiscales?.[0]?.axes || null; + } catch (_e) { + // multiscale metadata is optional — fall back to the default dataset path + } + + const levels = await Promise.all( + datasets.map(async ({ path }) => { + const arr = await open(rootNode.resolve(path), { kind: 'array' }); + return { arr, shape: arr.shape }; + }), + ); + + const { shape } = levels[0]; + const fullW = shape[shape.length - 1]; + const fullH = shape[shape.length - 2]; + + attachLevelTransforms(levels); + + return { + levels, + axesMetadata, + fullW, + fullH, + }; + })(); + promise.catch(() => pyramidCache.delete(omeZarrUrl)); + pyramidCache.set(omeZarrUrl, promise); + + // evict oldest entries (their stores + cached chunks are freed once unreferenced) + while (pyramidCache.size > PYRAMID_CACHE_MAX) { + pyramidCache.delete(pyramidCache.keys().next().value); + } + + return promise; +}; + +/** + * Pick the pyramid level + zarr index window for a data-space viewport. + * + * `viewport` = { xMin, xMax, yMin, yMax, outputWidth, outputHeight } in level-0 + * data coordinates. The level is the coarsest one that still has at least + * `outputWidth × outputHeight` pixels across the requested fraction of the slide, + * so zooming in selects progressively finer levels. The y window is flipped + * (zarr row 0 = top = high data-y) so cropped tiles align with the y-up plot. + * + * Returns null for an empty viewport. `level` is the chosen pyramid index + * (0 = full resolution); callers compare it to decide whether a detail tile adds + * any resolution over the coarse base tile. + */ +export const resolvePyramidRegion = (levels, fullW, fullH, viewport) => { + const { + xMin, xMax, yMin, yMax, outputWidth, outputHeight, + } = viewport; + + const vxMin = Math.max(0, Math.floor(xMin)); + const vxMax = Math.min(fullW, Math.ceil(xMax)); + const vyMin = Math.max(0, Math.floor(yMin)); + const vyMax = Math.min(fullH, Math.ceil(yMax)); + if (vxMax <= vxMin || vyMax <= vyMin) return null; + + const fracX = (vxMax - vxMin) / fullW; + const fracY = (vyMax - vyMin) / fullH; + + let level = 0; + for (let i = levels.length - 1; i >= 0; i -= 1) { + const lw = levels[i].shape[levels[i].shape.length - 1]; + const lh = levels[i].shape[levels[i].shape.length - 2]; + if (fracX * lw >= outputWidth && fracY * lh >= outputHeight) { + level = i; + break; + } + } + + const { shape } = levels[level]; + const ndim = shape.length; + const lh = shape[ndim - 2]; + const lw = shape[ndim - 1]; + const { k: kx, b: bx } = axisMapX(levels[level], fullW); + const { k: ky, b: by } = axisMapY(levels[level], fullH); + + const x0 = Math.max(0, Math.floor((vxMin - bx) * kx)); + const x1 = Math.min(lw, Math.ceil((vxMax - bx) * kx)); + const y0 = Math.max(0, Math.floor(((fullH - vyMax) - by) * ky)); + const y1 = Math.min(lh, Math.ceil(((fullH - vyMin) - by) * ky)); + + const regionW = x1 - x0; + const regionH = y1 - y0; + if (regionW <= 0 || regionH <= 0) return null; + + return { + level, + shape, + ndim, + x0, + x1, + y0, + y1, + regionW, + regionH, + extent: { + xMin: vxMin, xMax: vxMax, yMin: vyMin, yMax: vyMax, + }, + }; +}; + +// Pick the coarsest pyramid level that still has >= outputWidth x outputHeight +// pixels across the requested fraction of the slide (so deeper zoom selects finer +// levels). 0 = full resolution. Shared by the tile streamer. +export const pickLevel = (levels, fullW, fullH, viewport) => { + const { + xMin, xMax, yMin, yMax, outputWidth, outputHeight, + } = viewport; + const vxMin = Math.max(0, xMin); + const vxMax = Math.min(fullW, xMax); + const vyMin = Math.max(0, yMin); + const vyMax = Math.min(fullH, yMax); + const fracX = Math.max(0, vxMax - vxMin) / fullW; + const fracY = Math.max(0, vyMax - vyMin) / fullH; + + let level = 0; + for (let i = levels.length - 1; i >= 0; i -= 1) { + const lw = levels[i].shape[levels[i].shape.length - 1]; + const lh = levels[i].shape[levels[i].shape.length - 2]; + if (fracX * lw >= outputWidth && fracY * lh >= outputHeight) { + level = i; + break; + } + } + return level; +}; + +/** + * The tiles at `level` (TILE_SIZE level-pixels each) that intersect a data-space + * viewport. Each descriptor carries the level-pixel window [x0,x1)x[y0,y1) and the + * data-space extent it covers (y-flipped: zarr row 0 = top = high data-y), so tiles + * tile the plane without gaps or overlap and align with the y-up plot. + * + * @returns {Array<{ level, tx, ty, x0, x1, y0, y1, extent }>} + */ +export const tilesForViewport = (levels, fullW, fullH, level, viewport, tileSize) => { + const { shape } = levels[level]; + const lw = shape[shape.length - 1]; + const lh = shape[shape.length - 2]; + const { k: kx, b: bx } = axisMapX(levels[level], fullW); + const { k: ky, b: by } = axisMapY(levels[level], fullH); + + // viewport -> level pixels (x direct, y flipped) + const vx0 = Math.max(0, Math.floor((Math.max(0, viewport.xMin) - bx) * kx)); + const vx1 = Math.min(lw, Math.ceil((Math.min(fullW, viewport.xMax) - bx) * kx)); + const ry0 = Math.max(0, Math.floor(((fullH - Math.min(fullH, viewport.yMax)) - by) * ky)); + const ry1 = Math.min(lh, Math.ceil(((fullH - Math.max(0, viewport.yMin)) - by) * ky)); + if (vx1 <= vx0 || ry1 <= ry0) return []; + + const txStart = Math.floor(vx0 / tileSize); + const txEnd = Math.floor((vx1 - 1) / tileSize); + const tyStart = Math.floor(ry0 / tileSize); + const tyEnd = Math.floor((ry1 - 1) / tileSize); + + const tiles = []; + for (let ty = tyStart; ty <= tyEnd; ty += 1) { + for (let tx = txStart; tx <= txEnd; tx += 1) { + const x0 = tx * tileSize; + const y0 = ty * tileSize; + const x1 = Math.min(lw, (tx + 1) * tileSize); + const y1 = Math.min(lh, (ty + 1) * tileSize); + if (x1 <= x0 || y1 <= y0) continue; // eslint-disable-line no-continue + tiles.push({ + level, + tx, + ty, + x0, + x1, + y0, + y1, + // data extent (level-0 frame, forward affine) — row y0 (top) maps to the + // larger data-y (yMax). Using the shared per-level map keeps coarse and + // fine tiles registered, so streaming a sharper tile in never shifts it. + extent: { + xMin: x0 / kx + bx, + xMax: x1 / kx + bx, + yMin: fullH - (y1 / ky + by), + yMax: fullH - (y0 / ky + by), + }, + }); + } + } + return tiles; +}; diff --git a/src/pages/experiments/[experimentId]/data-processing/index.jsx b/src/pages/experiments/[experimentId]/data-processing/index.jsx index d6f890406d..2167d26ab9 100644 --- a/src/pages/experiments/[experimentId]/data-processing/index.jsx +++ b/src/pages/experiments/[experimentId]/data-processing/index.jsx @@ -46,6 +46,9 @@ import DoubletScores from 'components/data-processing/DoubletScores/DoubletScore import GenesVsUMIs from 'components/data-processing/GenesVsUMIs/GenesVsUMIs'; import Header from 'components/Header'; import MitochondrialContent from 'components/data-processing/MitochondrialContent/MitochondrialContent'; +import SpatialUmiOutlier from 'components/data-processing/SpatialUmiOutlier/SpatialUmiOutlier'; +import SpatialNumGenesOutlier from 'components/data-processing/SpatialNumGenesOutlier/SpatialNumGenesOutlier'; +import SpatialMitoOutlier from 'components/data-processing/SpatialMitoOutlier/SpatialMitoOutlier'; import PipelineRedirectToDataProcessing from 'components/PipelineRedirectToDataProcessing'; import PlatformError from 'components/PlatformError'; import PropTypes from 'prop-types'; @@ -59,7 +62,7 @@ import { loadSamples } from 'redux/actions/samples'; import { runQC } from 'redux/actions/pipeline'; import { useAppRouter } from 'utils/AppRouteProvider'; -import { modules } from 'utils/constants'; +import { modules, spatialTechs } from 'utils/constants'; import QCRerunDisabledModal from 'components/modals/QCRerunDisabledModal'; import isUserAuthorized from 'utils/isUserAuthorized'; import { getURL } from 'redux/actions/pipeline/runQC'; @@ -82,6 +85,9 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { const samples = useSelector((state) => state.samples); + const selectedTechnology = samples[sampleKeys?.[0]]?.type; + const isSpatial = spatialTechs.includes(selectedTechnology); + const pipelineStatusKey = pipelineStatus?.status; const pipelineRunning = pipelineStatusKey === 'RUNNING'; @@ -171,7 +177,7 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { processingConfig[step][key]?.prefiltered))); }; - const steps = [ + const allSteps = [ { key: 'classifier', name: getUserFriendlyQCStepName('classifier'), @@ -277,9 +283,10 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { A single barcode might correspond to more than one cell. In such cases, it is not possible to distinguish which reads came from which cell. - Such barcodes cause problems in the downstream analysis as they appear as an intermediate type. + Such barcodes cause problems in the downstream analysis as they appear + as an intermediate type. Barcodes with a high probability of being a doublet should be excluded. - The probability of being a doublet is calculated using "scDblFinder". + The probability of being a doublet is calculated using "scDblFinder". For each sample, the default threshold tries to minimize both the deviation in the expected number of doublets and the error of a trained classifier. For more details see {' '} @@ -306,6 +313,78 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { /> ), }, + { + key: 'spatialUmiOutlier', + name: getUserFriendlyQCStepName('spatialUmiOutlier'), + description: 'Spatial filter that removes cells whose total UMI count is a local outlier on the log scale, i.e. unusually low compared to their spatial neighborhood. Cells beyond the chosen z-score threshold are outlined in red on the tissue slide.', + multiSample: true, + render: (key) => ( + ( + onConfigChange(key)} + stepDisabled={!checkIfSampleIsEnabled(key)} + stepHadErrors={getStepHadErrors(key)} + /> + )} + /> + ), + }, + { + key: 'spatialNumGenesOutlier', + name: getUserFriendlyQCStepName('spatialNumGenesOutlier'), + description: 'Spatial filter that removes cells whose number of detected genes is a local outlier on the log scale, i.e. unusually low compared to their spatial neighborhood. Cells beyond the chosen z-score threshold are outlined in red on the tissue slide.', + multiSample: true, + render: (key) => ( + ( + onConfigChange(key)} + stepDisabled={!checkIfSampleIsEnabled(key)} + stepHadErrors={getStepHadErrors(key)} + /> + )} + /> + ), + }, + { + key: 'spatialMitoOutlier', + name: getUserFriendlyQCStepName('spatialMitoOutlier'), + description: 'Spatial filter that removes cells whose mitochondrial content is a local outlier, i.e. unusually high compared to their spatial neighborhood. Cells beyond the chosen z-score threshold are outlined in red on the tissue slide.', + multiSample: true, + render: (key) => ( + ( + onConfigChange(key)} + stepDisabled={!checkIfSampleIsEnabled(key)} + stepHadErrors={getStepHadErrors(key)} + /> + )} + /> + ), + }, { key: 'dataIntegration', name: getUserFriendlyQCStepName('dataIntegration'), @@ -331,11 +410,27 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { key={key} onConfigChange={(settingType) => onConfigChange(settingType)} stepHadErrors={getStepHadErrors(key)} + isSpatial={isSpatial} /> ), }, ]; + // check that the order and identities of the QC steps above match + // the canonical representation + console.assert(_.isEqual(qcSteps, allSteps.map((s) => s.key))); + + // Spatial (Visium HD) datasets run a different set of filters: cells are defined + // by segmentation, so the single-cell filters are replaced by spatial local-outlier + // filters. Show only the filter set matching the dataset's technology. + const SPATIAL_ONLY_STEPS = ['spatialUmiOutlier', 'spatialNumGenesOutlier', 'spatialMitoOutlier']; + const SINGLE_CELL_ONLY_STEPS = ['classifier', 'cellSizeDistribution', 'mitochondrialContent', 'numGenesVsNumUmis', 'doubletScores']; + const steps = allSteps.filter((s) => { + if (SPATIAL_ONLY_STEPS.includes(s.key)) return isSpatial; + if (SINGLE_CELL_ONLY_STEPS.includes(s.key)) return !isSpatial; + return true; // dataIntegration & configureEmbedding always shown + }); + const currentStep = steps[stepIdx]; const getStepHadErrors = (key) => pipelineHadErrors && !isStepComplete(key); @@ -347,10 +442,6 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { return disabledPendingExecution || disabledByError; }; - // check that the order and identities of the QC steps above match - // the canonical representation - console.assert(_.isEqual(qcSteps, steps.map((s) => s.key))); - const changeStepId = (newStepIdx) => { setStepIdx(newStepIdx); }; @@ -553,7 +644,11 @@ const DataProcessingPage = ({ experimentId, experimentData }) => { experimentId={experimentId} allSteps={steps} currentStep={stepIdx} - completedSteps={completedSteps} + completedSteps={completedSteps.filter( + (stepPipelineName) => steps.some( + (step) => stepPipelineName.toLowerCase().includes(step.key.toLowerCase()), + ), + )} /> @@ -697,12 +792,12 @@ const DataProcessingPage = ({ experimentId, experimentData }) => {

{ !(changedQCFilters.size === 1 && changedQCFilters.has('embeddingSettings')) - && ( - - ) + && ( + + ) } ) diff --git a/src/pages/experiments/[experimentId]/plots-and-tables/index.jsx b/src/pages/experiments/[experimentId]/plots-and-tables/index.jsx index ef05e48d4e..e4128a3dae 100644 --- a/src/pages/experiments/[experimentId]/plots-and-tables/index.jsx +++ b/src/pages/experiments/[experimentId]/plots-and-tables/index.jsx @@ -5,7 +5,7 @@ import Header from 'components/Header'; import PlotsTablesContainer from 'components/plots/PlotsTablesContainer'; import SingleTileContainer from 'components/SingleTileContainer'; -import { spatialTechs } from 'utils/constants'; +import { spatialTechs, moleculeTechs } from 'utils/constants'; const PlotsTablesHome = (props) => { const { experimentId, experimentData } = props; @@ -14,6 +14,7 @@ const PlotsTablesHome = (props) => { const selectedTechnology = (samples[experimentData?.sampleIds?.[0]]?.type || false); const isSpatial = spatialTechs.includes(selectedTechnology); + const hasMolecules = moleculeTechs.includes(selectedTechnology); return ( <> @@ -23,7 +24,11 @@ const PlotsTablesHome = (props) => { title='Plots and Tables' /> - + ); diff --git a/src/pages/experiments/[experimentId]/plots-and-tables/spatial-categorical/index.jsx b/src/pages/experiments/[experimentId]/plots-and-tables/spatial-categorical/index.jsx index 496d0a4496..158e8e3d5b 100644 --- a/src/pages/experiments/[experimentId]/plots-and-tables/spatial-categorical/index.jsx +++ b/src/pages/experiments/[experimentId]/plots-and-tables/spatial-categorical/index.jsx @@ -3,7 +3,6 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { useSelector, useDispatch } from 'react-redux'; import MultiViewPlotEditor from 'components/plots/styling/MultiViewPlotEditor'; -import _ from 'lodash'; import { Collapse, Select, @@ -15,13 +14,12 @@ import MultiViewPlotGrid from 'components/plots/MultiViewPlotGrid'; import SelectData from 'components/plots/styling/embedding-continuous/SelectData'; import Header from 'components/Header'; import PlotContainer from 'components/plots/PlotContainer'; -import { loadGeneExpression } from 'redux/actions/genes'; import { updatePlotConfig, } from 'redux/actions/componentConfig/index'; import { loadCellSets } from 'redux/actions/cellSets'; -import { getCellSets, getPlotConfigs, getCellSetsHierarchy } from 'redux/selectors'; +import { getCellSets, getCellSetsHierarchy, getPlotConfigs } from 'redux/selectors'; import { plotNames, plotUuids, plotTypes } from 'utils/constants'; import SpatialCategoricalReduxWrapper from 'components/plots/SpatialCategoricalReduxWrapper'; @@ -42,7 +40,6 @@ const SpatialCategoricalPage = ({ experimentId }) => { const [selectedPlotUuid, setSelectedPlotUuid] = useState(`${plotUuid}-0`); const [updateAll, setUpdateAll] = useState(true); - useEffect(() => { dispatch(loadCellSets(experimentId)); }, []); @@ -73,16 +70,16 @@ const SpatialCategoricalPage = ({ experimentId }) => { ], }, { - panelTitle: 'Axes and margins', - controls: ['axesWithRanges'], + panelTitle: 'Axes options', + controls: ['axes'], }, { panelTitle: 'Colour inversion', controls: ['colourInversion'], }, { - panelTitle: 'Markers', - controls: ['markers'], + panelTitle: 'Segmentations', + controls: [{ name: 'markers', props: { spatial: true } }], }, { panelTitle: 'Legend', @@ -113,7 +110,6 @@ const SpatialCategoricalPage = ({ experimentId }) => { ); @@ -132,8 +128,10 @@ const SpatialCategoricalPage = ({ experimentId }) => { diff --git a/src/pages/experiments/[experimentId]/plots-and-tables/spatial-feature/index.jsx b/src/pages/experiments/[experimentId]/plots-and-tables/spatial-feature/index.jsx index ee8a59a2bf..b70ebdb52c 100644 --- a/src/pages/experiments/[experimentId]/plots-and-tables/spatial-feature/index.jsx +++ b/src/pages/experiments/[experimentId]/plots-and-tables/spatial-feature/index.jsx @@ -74,16 +74,16 @@ const SpatialFeaturePage = ({ experimentId }) => { ], }, { - panelTitle: 'Axes and margins', - controls: ['axesWithRanges'], + panelTitle: 'Axes options', + controls: ['axes'], }, { panelTitle: 'Colours', controls: ['colourScheme', 'colourInversion'], }, { - panelTitle: 'Markers', - controls: ['markers'], + panelTitle: 'Segmentations', + controls: [{ name: 'markers', props: { spatial: true } }], }, { panelTitle: 'Legend', @@ -112,7 +112,6 @@ const SpatialFeaturePage = ({ experimentId }) => { ); @@ -147,7 +146,9 @@ const SpatialFeaturePage = ({ experimentId }) => { { + const dispatch = useDispatch(); + const cellSets = useSelector(getCellSets()); + + const multiViewConfig = useSelector((state) => state.componentConfig[multiViewUuid]?.config); + const multiViewPlotUuids = multiViewConfig?.plotUuids; + const plotConfigs = useSelector(getPlotConfigs(multiViewPlotUuids)); + + const [selectedPlotUuid, setSelectedPlotUuid] = useState(`${plotUuid}-0`); + const [updateAll, setUpdateAll] = useState(true); + + // config of the plot currently selected in the multi-view editor; the molecule + // panels (genes / segmentation) read from it and write to it (or to all plots). + const selectedConfig = plotConfigs[selectedPlotUuid]; + + useEffect(() => { + dispatch(loadCellSets(experimentId)); + // gene list powers the search bar + default-gene selection + dispatch(loadGeneList(experimentId)); + }, []); + + const sampleCellSets = { + ...cellSets, + hierarchy: cellSets.hierarchy.filter((item) => item.key === 'sample'), + }; + + const updateAllWithChanges = (updateField) => { + (multiViewPlotUuids ?? []).forEach((uuid) => { + dispatch(updatePlotConfig(uuid, updateField)); + }); + }; + const updatePlotWithChanges = (updateField) => { + dispatch(updatePlotConfig(selectedPlotUuid, updateField)); + }; + const onUpdate = updateAll ? updateAllWithChanges : updatePlotWithChanges; + + // Reset every plot's STYLING back to defaults while keeping the grid layout and + // each plot's selected genes / colours / sample (the keepValuesOnReset fields). + const resetAllPlotStyles = () => { + const defaultConfig = initialPlotConfigStates[plotType]; + const keysToPreserve = defaultConfig.keepValuesOnReset || []; + (multiViewPlotUuids ?? []).forEach((uuid) => { + const current = plotConfigs[uuid]; + const resetConfig = keysToPreserve.reduce((acc, key) => { + if (current?.[key] !== undefined) acc[key] = current[key]; + return acc; + }, _.cloneDeep(defaultConfig)); + dispatch(updatePlotConfig(uuid, resetConfig)); + }); + }; + + const renderPlot = (plotUuidToRender) => ( + + ); + + const plotStylingConfig = [ + { + panelTitle: 'Main schema', + controls: ['dimensions'], + children: [ + { panelTitle: 'Title', controls: ['title'] }, + { panelTitle: 'Font', controls: ['font'] }, + ], + }, + { panelTitle: 'Axes options', controls: [{ name: 'axes', props: { showOffset: false } }] }, + { panelTitle: 'Colour inversion', controls: ['colourInversion'] }, + { + panelTitle: 'Markers', + controls: [{ + name: 'markers', + props: { + showShapeType: false, pointSizeMin: 0.1, pointSizeMax: 10, pointSizeStep: 0.1, + }, + }], + }, + { + panelTitle: 'Legend', + controls: [{ name: 'legend', props: { option: { positions: 'top-bottom' } } }], + }, + ]; + + const selectedGenes = selectedConfig?.selectedGenes ?? []; + const geneColors = selectedConfig?.geneColors ?? {}; + // Resolved (displayed) colour per gene — the same allocation the plot renders, so + // the picker swatches match the points even before defaults are persisted. + const resolvedColors = resolveGeneColors(selectedGenes, geneColors); + + const addGenes = (genes) => { + const merged = Array.from(new Set([...selectedGenes, ...genes])); + if (merged.length > MAX_MOLECULE_GENES) { + pushNotificationMessage( + 'error', + `You can plot at most ${MAX_MOLECULE_GENES} genes at once. Remove some to add more.`, + ); + } + onUpdate({ selectedGenes: merged.slice(0, MAX_MOLECULE_GENES) }); + }; + + const removeGene = (gene) => { + // The config reducer deep-merges objects, so deleting a geneColors key never + // propagates — null it out instead (merge overwrites with null). The resolver + // treats null as "no override", so re-adding the gene allocates a fresh colour. + onUpdate({ + selectedGenes: selectedGenes.filter((g) => g !== gene), + geneColors: { ...geneColors, [gene]: null }, + }); + }; + + const setGeneColor = (gene, color) => { + onUpdate({ geneColors: { ...geneColors, [gene]: color } }); + }; + + const renderExtraPanels = () => ( + <> + +

+ Choose which transcript molecules to show. A few genes are selected by + default; add or remove genes to compare their spatial distributions. + {' '} + Up to + {' '} + {MAX_MOLECULE_GENES} + {' '} + genes ( + {selectedGenes.length} + {' '} + selected). +

+ + + {selectedGenes.map((gene) => ( + + setGeneColor(gene, color)} + /> + {gene} + removeGene(gene)} + style={{ cursor: 'pointer' }} + /> + + ))} + +
+ + + + + +

Outline Opacity:

+ +
+ onUpdate({ segmentationOutlineOpacity: val })} + sliderWidth={150} + /> + onUpdate({ segmentationOutlineColour: color })} + size='small' + /> + onUpdate({ showSegmentationOutlines: e.target.checked })} + /> +
+
+ +
+ + + + + ); + + return ( + <> +
+ + + + + ); +}; + +SpatialMoleculesPage.propTypes = { + experimentId: PropTypes.string.isRequired, +}; + +export default SpatialMoleculesPage; diff --git a/src/redux/actions/experimentSettings/processingConfig/updateFilterSettings.js b/src/redux/actions/experimentSettings/processingConfig/updateFilterSettings.js index b9c2e547e7..12aa581b31 100644 --- a/src/redux/actions/experimentSettings/processingConfig/updateFilterSettings.js +++ b/src/redux/actions/experimentSettings/processingConfig/updateFilterSettings.js @@ -1,6 +1,6 @@ import { EXPERIMENT_SETTINGS_SAMPLE_FILTER_UPDATE, EXPERIMENT_SETTINGS_NON_SAMPLE_FILTER_UPDATE } from '../../../actionTypes/experimentSettings'; -const settingsWithSampleId = ['classifier', 'cellSizeDistribution', 'doubletScores', 'mitochondrialContent', 'numGenesVsNumUmis']; +const settingsWithSampleId = ['classifier', 'cellSizeDistribution', 'doubletScores', 'mitochondrialContent', 'numGenesVsNumUmis', 'spatialUmiOutlier', 'spatialNumGenesOutlier', 'spatialMitoOutlier']; const settingsWithoutSampleId = ['dataIntegration', 'configureEmbedding', 'meta', 'defaultFilterSettings']; /** diff --git a/src/redux/actions/experimentSettings/processingConfig/updateProcessingSettingsFromQC.js b/src/redux/actions/experimentSettings/processingConfig/updateProcessingSettingsFromQC.js index 8d9e9536b5..fd24677611 100644 --- a/src/redux/actions/experimentSettings/processingConfig/updateProcessingSettingsFromQC.js +++ b/src/redux/actions/experimentSettings/processingConfig/updateProcessingSettingsFromQC.js @@ -1,6 +1,6 @@ import { EXPERIMENT_SETTINGS_UPDATE_SAMPLE_FROM_QC, EXPERIMENT_SETTINGS_NON_SAMPLE_FILTER_UPDATE } from '../../../actionTypes/experimentSettings'; -const settingsWithSampleId = ['classifier', 'cellSizeDistribution', 'doubletScores', 'mitochondrialContent', 'numGenesVsNumUmis']; +const settingsWithSampleId = ['classifier', 'cellSizeDistribution', 'doubletScores', 'mitochondrialContent', 'numGenesVsNumUmis', 'spatialUmiOutlier', 'spatialNumGenesOutlier', 'spatialMitoOutlier']; const settingsWithoutSampleId = ['dataIntegration', 'configureEmbedding', 'meta', 'defaultFilterSettings']; /** diff --git a/src/redux/reducers/componentConfig/baseStylesState.js b/src/redux/reducers/componentConfig/baseStylesState.js index 54489821af..90102f6a99 100644 --- a/src/redux/reducers/componentConfig/baseStylesState.js +++ b/src/redux/reducers/componentConfig/baseStylesState.js @@ -62,7 +62,7 @@ const markerBaseState = { showOpacity: true, opacity: 5, size: 5, - outline: true, + outline: false, }; const labelBaseState = { diff --git a/src/redux/reducers/componentConfig/initialState.js b/src/redux/reducers/componentConfig/initialState.js index 7713f24d07..85f3602be9 100644 --- a/src/redux/reducers/componentConfig/initialState.js +++ b/src/redux/reducers/componentConfig/initialState.js @@ -63,10 +63,10 @@ const spatialCategoricalInitialConfig = { }, axesRanges: { ...axesRangesBaseState, - xMin: 50, - xMax: 450, - yMin: 70, - yMax: 450, + xMin: 2000, + xMax: 2500, + yMin: 2000, + yMax: 2500, }, title: { ...titleBaseState, @@ -77,8 +77,8 @@ const spatialCategoricalInitialConfig = { marker: { ...markerBaseState, size: 20, - outline: false, - opacity: 10, + outline: true, + opacity: 9, }, labels: { ...labelBaseState, @@ -88,6 +88,10 @@ const spatialCategoricalInitialConfig = { selectedCellSet: 'louvain', selectedSample: null, showImage: true, + // selectedSample is null by default and auto-populated with the first sample on + // mount, so excluding it from the reset comparison lets the button disable after a + // reset (and keeps the current sample rather than resetting which sample is shown). + keepValuesOnReset: ['selectedSample'], }; // PLOTS & TABLES - Continuous Embedding @@ -141,10 +145,10 @@ const spatialFeatureInitialConfig = { }, axesRanges: { ...axesRangesBaseState, - xMin: 50, - xMax: 450, - yMin: 70, - yMax: 450, + xMin: 2000, + xMax: 2500, + yMin: 2000, + yMax: 2500, }, title: { ...titleBaseState, @@ -159,8 +163,8 @@ const spatialFeatureInitialConfig = { marker: { ...markerBaseState, size: 20, - outline: false, - opacity: 10, + outline: true, + opacity: 9, }, labels: labelBaseState, logEquation: 'datum.expression*1', @@ -168,10 +172,64 @@ const spatialFeatureInitialConfig = { expressionValue: 'raw', truncatedValues: true, selectedSample: null, - keepValuesOnReset: ['shownGene'], + // 'title' is auto-set to the gene name by the default-gene effect, and + // 'selectedSample' is auto-populated with the first sample on mount; keep both on + // reset (and skip them in the reset-disabled comparison) so those automatic writes + // don't permanently/repeatedly re-enable the Reset Plot button. + keepValuesOnReset: ['shownGene', 'title', 'selectedSample'], showImage: true, }; +// PLOTS & TABLES - Spatial Molecules Plot +// Renders individual transcript molecules (one symbol per molecule) coloured by +// gene, read from the molecules_by_gene artifact. One sample at a fixed extent; +// the bounded molecule result is inlined as Vega `values` (no streaming). +const spatialMoleculeInitialConfig = { + spec: '1.0.0', + legend: legendBaseState, + dimensions: { + ...dimensionsBaseState, + width: 500, + height: 500, + }, + axes: { + ...axesBaseState, + defaultValues: ['x', 'y'], + offset: 0, + }, + axesRanges: axesRangesBaseState, + title: { + ...titleBaseState, + dx: 0, + fontSize: 20, + }, + fontStyle: fontStyleBaseState, + colour: colourBaseState, + marker: { + ...markerBaseState, + size: 0.4, + opacity: 8, + }, + labels: labelBaseState, + // genes to render (gene symbols). Empty on init; the plot seeds a few default + // genes once the artifact loads (rendering every gene hangs Vega). + selectedGenes: [], + // per-gene render colours (gene symbol -> hex). Defaulted from the + // palette (first available colour) when a gene is seeded/added; the user can + // override each with the colour picker in the gene-selection panel. + geneColors: {}, + selectedSample: null, + // segmentation outlines drawn behind the molecules (colour + opacity adjustable). + showSegmentationOutlines: true, + segmentationOutlineColour: '#CECBCB', + segmentationOutlineOpacity: 0.05, + // 'selectedSample'/'selectedGenes'/'geneColors' are auto-populated on mount (first + // sample + default genes + their palette colours). Reset Plot resets the styling + // but keeps those (skipped in the reset-disabled comparison so the automatic writes + // don't permanently re-enable the button). + keepValuesOnReset: ['selectedSample', 'selectedGenes', 'geneColors'], +}; + // PLOTS & TABLES - Heatmap const heatmapInitialConfig = { spec: '1.0.0', @@ -529,7 +587,7 @@ const embeddingPreviewMitochondrialContentInitialConfig = { fontStyle: fontStyleBaseState, colour: { ...colourBaseState, - gradient: 'spectral', + gradient: 'inferno', }, marker: markerBaseState, labels: labelBaseState, @@ -564,7 +622,8 @@ const embeddingPreviewDoubletScoreInitialConfig = { fontStyle: fontStyleBaseState, colour: { ...colourBaseState, - gradient: 'spectral', + // inferno; not reversed so high doublet score = bright. + gradient: 'inferno', }, marker: markerBaseState, labels: labelBaseState, @@ -597,7 +656,9 @@ const embeddingPreviewNumOfGenesInitialConfig = { fontStyle: fontStyleBaseState, colour: { ...colourBaseState, - gradient: 'spectral', + // inferno (matches spatial QC "Genes detected"); reversed so low genes = bright. + gradient: 'inferno', + reverseCbar: true, }, marker: markerBaseState, labels: labelBaseState, @@ -629,7 +690,9 @@ const embeddingPreviewNumOfUmisInitialConfig = { fontStyle: fontStyleBaseState, colour: { ...colourBaseState, - gradient: 'spectral', + // inferno (matches spatial QC "UMIs"); reversed so low UMIs = bright. + gradient: 'inferno', + reverseCbar: true, }, marker: markerBaseState, labels: labelBaseState, @@ -648,7 +711,12 @@ const interactiveHeatmapInitialConfig = { const interactiveSpatialInitialConfig = { showImages: true, showSegmentations: true, + showSegmentationOutlines: true, groupSlidesBy: ['sample'], + // Xenium transcript/molecule overlay (only available when a molecules_by_gene + // was built). Off by default. When on AND a gene is being plotted, the focused + // gene's transcripts render as points in place of the per-cell expression fill. + showMolecules: false, }; // CELL SIZE DISTRIBUTION - Cell Size Distribution Histogram @@ -886,6 +954,65 @@ const doubletScoreHistogram = { probThreshold: 0.2, }; +// DATA PROCESSING - Spatial local-outlier filters (Visium HD) +// Main plot: segmentations coloured by the metric (tissue image hidden by default +// so the colouring reads clearly). `shownGene` is repurposed as the colour-legend +// title (the metric name). Default gradient is 'inferno'. +const makeSpatialOutlierPlotConfig = (legendTitle) => ({ + ...spatialFeatureInitialConfig, + shownGene: legendTitle, + keepValuesOnReset: [], + truncatedValues: false, + showImage: false, + dimensions: { ...spatialFeatureInitialConfig.dimensions, width: 450, height: 450 }, + colour: { ...spatialFeatureInitialConfig.colour, gradient: 'default' }, +}); + +// UMI and Genes-detected outliers are LOW-value cells; mito outliers are HIGH-value. +// On the white↔red 'default' scale, reverseCbar=true puts red at the LOW end (so low +// UMIs / few genes = red), while mito keeps the default (high mito = red) — i.e. the +// outlier end is red on all three. Users can flip this via the Colours panel. +const makeReversedOutlierPlotConfig = (legendTitle) => { + const base = makeSpatialOutlierPlotConfig(legendTitle); + return { + ...base, + colour: { ...base.colour, reverseCbar: true }, + dimensions: { ...base.dimensions, width: 450, height: 450 }, + }; +}; + +const spatialUmiOutlierPlot = makeReversedOutlierPlotConfig('UMIs'); +const spatialNumGenesOutlierPlot = makeReversedOutlierPlotConfig('Genes detected'); +const spatialMitoOutlierPlot = makeSpatialOutlierPlotConfig('Mitochondrial %'); + +// Outlier-highlight slide: a SEPARATE, independently-styleable config from the +// metric slide (so e.g. inverting the background only affects this view). Defaults +// to hiding the tissue image — outliers are filled red, non-outliers grey, both at +// the slider opacity (see SpatialOutlierFilterPlot). +const makeSpatialOutlierHighlightConfig = (legendTitle) => ({ + ...makeSpatialOutlierPlotConfig(legendTitle), + showImage: false, +}); + +const spatialUmiOutlierHighlightPlot = makeSpatialOutlierHighlightConfig('UMIs'); +const spatialNumGenesOutlierHighlightPlot = makeSpatialOutlierHighlightConfig('Genes detected'); +const spatialMitoOutlierHighlightPlot = makeSpatialOutlierHighlightConfig('Mitochondrial %'); + +// Mini plot: histogram of the local-outlier z-scores with a cutoff rule. +const makeZscoreHistogram = (xAxisText) => ({ + ...doubletScoreHistogram, + axes: { + ...axesBaseState, + xAxisText, + yAxisText: 'Frequency', + }, + cutoff: 3, +}); + +const spatialUmiOutlierZscoreHistogram = makeZscoreHistogram('UMI outlier z-score'); +const spatialNumGenesOutlierZscoreHistogram = makeZscoreHistogram('Genes detected outlier z-score'); +const spatialMitoOutlierZscoreHistogram = makeZscoreHistogram('Mitochondrial outlier z-score'); + // DATA INTEGRATION - Embedding by Samples const dataIntegrationEmbeddingInitialConfig = { spec: '1.0.0', @@ -1022,10 +1149,20 @@ const initialPlotConfigStates = { classifierEmptyDropsPlot, featuresVsUMIsScatterplot, doubletScoreHistogram, + spatialUmiOutlierPlot, + spatialNumGenesOutlierPlot, + spatialMitoOutlierPlot, + spatialUmiOutlierHighlightPlot, + spatialNumGenesOutlierHighlightPlot, + spatialMitoOutlierHighlightPlot, + spatialUmiOutlierZscoreHistogram, + spatialNumGenesOutlierZscoreHistogram, + spatialMitoOutlierZscoreHistogram, embeddingCategorical: embeddingCategoricalInitialConfig, embeddingContinuous: embeddingContinuousInitialConfig, [plotTypes.SPATIAL_FEATURE]: spatialFeatureInitialConfig, [plotTypes.SPATIAL_CATEGORICAL]: spatialCategoricalInitialConfig, + [plotTypes.SPATIAL_MOLECULES]: spatialMoleculeInitialConfig, heatmap: heatmapInitialConfig, volcano: volcanoInitialConfig, markerHeatmap: markerHeatmapInitialConfig, diff --git a/src/utils/constants.js b/src/utils/constants.js index bc25551812..e5044e9ea8 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -19,6 +19,8 @@ const modules = { const sampleTech = { '10X': '10x', H5: '10x_h5', + VISIUM_HD: 'visium_hd', + XENIUM: 'xenium', SEURAT_OBJECT: 'seurat_object', SEURAT_SPATIAL_OBJECT: 'seurat_spatial_object', SCE_OBJECT: 'sce_object', @@ -34,7 +36,16 @@ const obj2sTechs = [ sampleTech.SEURAT_SPATIAL_OBJECT, ]; -const spatialTechs = [sampleTech.SEURAT_SPATIAL_OBJECT]; +const spatialTechs = [sampleTech.SEURAT_SPATIAL_OBJECT, sampleTech.VISIUM_HD, sampleTech.XENIUM]; + +// spatial technologies with no tissue image: cells render on a blank background +// and no ome_zarr_zip tissue image is produced, so the image URL is not fetched +const imagelessTechs = [sampleTech.XENIUM]; + +// spatial technologies that can carry individual transcript molecules (a +// molecules_by_gene artifact, built from the required transcripts.parquet input). +// The Spatial Molecules plot type is only offered for these techs. +const moleculeTechs = [sampleTech.XENIUM]; const plotTypes = { CONTINUOUS_EMBEDDING: 'embeddingContinuous', @@ -48,10 +59,15 @@ const plotTypes = { NORMALIZED_EXPRESSION_MATRIX: 'NormalizedExpressionMatrix', SPATIAL_CATEGORICAL: 'SpatialCategorical', SPATIAL_FEATURE: 'SpatialFeature', + SPATIAL_MOLECULES: 'SpatialMolecules', MULTI_VIEW_PLOT: 'multiView', }; -const spatialPlotTypes = [plotTypes.SPATIAL_CATEGORICAL, plotTypes.SPATIAL_FEATURE]; +const spatialPlotTypes = [ + plotTypes.SPATIAL_CATEGORICAL, + plotTypes.SPATIAL_FEATURE, + plotTypes.SPATIAL_MOLECULES, +]; const plotUuids = { CONTINUOUS_EMBEDDING: 'embeddingContinuousMain', @@ -65,6 +81,7 @@ const plotUuids = { NORMALIZED_EXPRESSION_MATRIX: 'normalized-matrix', SPATIAL_CATEGORICAL: 'spatialCategoricalMain', SPATIAL_FEATURE: 'spatialFeatureMain', + SPATIAL_MOLECULES: 'spatialMoleculesMain', getMultiPlotUuid: (plotType) => `${plotTypes.MULTI_VIEW_PLOT}-${plotType}`, }; @@ -81,9 +98,14 @@ const plotNames = { BATCH_DIFFERENTIAL_EXPRESSION: 'Batch Differential Expression Table', SPATIAL_CATEGORICAL: 'Spatial Categorical Plot', SPATIAL_FEATURE: 'Spatial Feature Plot', + SPATIAL_MOLECULES: 'Spatial Molecules Plot', }; -const spatialPlotNames = [plotNames.SPATIAL_CATEGORICAL, plotNames.SPATIAL_FEATURE]; +const spatialPlotNames = [ + plotNames.SPATIAL_CATEGORICAL, + plotNames.SPATIAL_FEATURE, + plotNames.SPATIAL_MOLECULES, +]; const layout = { PANEL_HEADING_HEIGHT: 30, @@ -107,6 +129,8 @@ export { sampleTech, obj2sTechs, spatialTechs, + imagelessTechs, + moleculeTechs, plotTypes, spatialPlotTypes, spatialPlotNames, diff --git a/src/utils/plotSpecs/generateElbowSpec.js b/src/utils/plotSpecs/generateElbowSpec.js index e6088c76e8..e7a72c653f 100644 --- a/src/utils/plotSpecs/generateElbowSpec.js +++ b/src/utils/plotSpecs/generateElbowSpec.js @@ -19,14 +19,11 @@ const generateSpec = (config, plotData, numPCs) => { data: [ { name: 'plotData', - values: plotData, - // Vega internally modifies objects during data transforms. If the plot data is frozen, - // Vega is not able to carry out the transform and will throw an error. - // https://github.com/vega/vega/issues/2453#issuecomment-604516777 - format: { - type: 'json', - copy: true, - }, + // Shallow-clone each datum: the `percent` formula transform below mutates the + // tuples, but the source array comes from the (frozen) redux store, so writing + // to the originals throws "Cannot assign to read only property 'percent'". + // (format.copy is unreliable for already-parsed values, so clone explicitly.) + values: (plotData || []).map((datum) => ({ ...datum })), transform: [ { type: 'formula', diff --git a/src/utils/plotSpecs/generateEmbeddingContinuousSpec.js b/src/utils/plotSpecs/generateEmbeddingContinuousSpec.js index 822ef71249..4eb876a059 100644 --- a/src/utils/plotSpecs/generateEmbeddingContinuousSpec.js +++ b/src/utils/plotSpecs/generateEmbeddingContinuousSpec.js @@ -84,7 +84,9 @@ const generateSpec = (config, method, plotData) => { count: 5, }, domain: { data: 'plotData', field: 'value' }, - reverse: config.colour.reverseCbar, + // spectral defaults to reversed; reverseCbar flips that (XOR). Mirrors the + // spatial QC feature spec so the gene/UMI-count embeddings match those plots. + reverse: (config.colour.gradient === 'spectral') !== Boolean(config.colour.reverseCbar), }, ], axes: [ diff --git a/src/utils/plotSpecs/generateSpatialCategoricalSpec.js b/src/utils/plotSpecs/generateSpatialCategoricalSpec.js index 0c1a7f940f..39c9ceb674 100644 --- a/src/utils/plotSpecs/generateSpatialCategoricalSpec.js +++ b/src/utils/plotSpecs/generateSpatialCategoricalSpec.js @@ -2,20 +2,44 @@ import { getAllCells, getSampleCells } from 'utils/cellSets'; import _ from 'lodash'; +import spatialZoomSignals from 'utils/plotSpecs/spatialZoomSignals'; const paddingSize = 5; -const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) => { - const { imageUrl, imageWidth, imageHeight } = imageData; - - const xScaleDomain = config.axesRanges.xAxisAuto - ? [0, imageWidth] - : [config.axesRanges.xMin, config.axesRanges.xMax]; - - const yScaleDomain = config.axesRanges.yAxisAuto - ? [0, imageHeight] - : [config.axesRanges.yMin, config.axesRanges.yMax]; - +/** + * @param {object} config + * @param {string} method + * @param {object} imageData { imageWidth, imageHeight } — level-0 full + * dims for the Vega scale domains. The tissue tiles stream in via the `data` prop + * (dataset 'tissueImageData'); see useSpatialStream. + * @param {Array} plotData + * @param {Array} cellSetLegendsData + */ +// hasSegmentation chooses the (data-driven) overlay mark vs the centroid fallback. +// Both tissue tiles and the overlay are supplied at runtime via the Vega `data` +// prop ('tissueImageData' / 'segOverlayData') so streaming sharper tiles + +// recolouring update the view in place — no rebuild. +const generateSpec = ( + config, + method, + imageData, + plotData, + cellSetLegendsData, + hasSegmentation = false, +) => { + const { imageWidth, imageHeight } = imageData; + + // Initial zoom/pan domains are ALWAYS the full image extent — the spec is + // intentionally invariant to config.axesRanges so persisting a zoom never changes + // the spec CONTENT (react-vega's VegaEmbed rebuilds on an expensive spec change), + // which would flicker the slide. The persisted zoom (config.axesRanges) is + // re-applied imperatively after (re)build via the plot's onNewView (restoreZoom). + const initXdom = [0, imageWidth]; + const initYdom = [0, imageHeight]; + + // Plot size always matches the containing box (config.dimensions) — the styling + // panel for the full plot, the fixed mini-preview tile (MiniPlot) for previews. + // The full image extent is mapped into that box via the x/y scale domains. const plotWidth = config.dimensions.width; const plotHeight = config.dimensions.height; @@ -23,166 +47,174 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = if (config.legend.enabled) { const positionIsRight = config.legend.position === 'right'; - - // Approximate the size of each name. - // All names can have that size or less, so can use it calculate the amount of columns - // - // The size of each name is calculated by getting the amount of chars in - // each name and multiplying by each approx char size, 5.5 - // plus 30 for the color symbol and offset const colorSymbolSize = 30; const characterSizeHorizontal = 5.5; const characterSizeVertical = 11; const xTickSize = 140; const maxLegendItemsPerCol = Math.floor( - (config.dimensions.height - xTickSize - (2 * paddingSize)) - / characterSizeVertical, + (config.dimensions.height - xTickSize - (2 * paddingSize)) / characterSizeVertical, ); - const legendSize = colorSymbolSize + _.max( cellSetLegendsData.map((legendData) => legendData.name.length * characterSizeHorizontal), ); - - // only 20 rows per column if the legend is on the right const legendColumns = positionIsRight ? Math.ceil(cellSetLegendsData.length / maxLegendItemsPerCol) - : Math.floor((config.dimensions.width) / legendSize); + : Math.floor(config.dimensions.width / legendSize); const labelLimit = positionIsRight ? 0 : legendSize; - legend = [ - { - fill: 'cellSetLabelColors', - title: config?.legend.title === '' ? null : (config?.legend.title || 'Cluster Name'), - titleColor: config?.colour.masterColour, - type: 'symbol', - orient: config?.legend.position, - offset: 40, - symbolType: 'circle', - symbolSize: 100, - encode: { - labels: { - update: { - text: { - scale: 'sampleToName', field: 'label', - }, - fill: { value: config?.colour.masterColour }, - }, + legend = [{ + fill: 'cellSetLabelColors', + title: config?.legend.title === '' ? null : (config?.legend.title || 'Cluster Name'), + titleColor: config?.colour.masterColour, + titleFontSize: config?.legend.titleFontSize, + labelFontSize: config?.legend.labelFontSize, + type: 'symbol', + orient: config?.legend.position, + offset: 40, + symbolType: 'circle', + symbolSize: 100, + encode: { + labels: { + update: { + text: { scale: 'sampleToName', field: 'label' }, + fill: { value: config?.colour.masterColour }, }, }, - direction: 'horizontal', - labelFont: config?.fontStyle.font, - titleFont: config?.fontStyle.font, - symbolLimit: 0, - columns: legendColumns, - labelLimit, }, - ]; + direction: 'horizontal', + labelFont: config?.fontStyle.font, + titleFont: config?.fontStyle.font, + symbolLimit: 0, + columns: legendColumns, + labelLimit, + }]; } - let marks = [{ - type: 'symbol', - clip: true, - from: { data: 'values' }, - encode: { - update: { - x: { scale: 'x', field: 'x' }, - y: { scale: 'y', field: 'flipped_y' }, - size: [ - { value: config?.marker.size }, - ], - stroke: config?.marker.outline ? { - scale: 'cellSetMarkColors', - field: 'cellSetKey', - } : null, - fill: { - scale: 'cellSetMarkColors', - field: 'cellSetKey', - }, - shape: { value: config?.marker.shape }, - fillOpacity: { value: config.marker.opacity / 10 }, - }, - }, - }]; + // ── Marks ─────────────────────────────────────────────────────────────────── + // Rendering order (bottom → top): + // 1. Tissue image (optional, config.showImage) + // 2a. Segmentation overlay (when segmentationOverlay is available) + // 2b. Centroid dots (fallback when no overlay) + // 3. Cluster labels (optional, config.labels.enabled) + // 4. Label background rect (optional, config.labels.enabled) + const marks = []; + + // Tile image marks (tissue + segmentation) share this encoding. Edges are SNAPPED + // to integer device pixels with round() so adjacent tiles share the exact same edge + // pixel — no sub-pixel blank seam between tiles, and no overlap either. + const tileEncode = { + url: { field: 'url' }, + x: { signal: 'round(scale("x", datum.x1))' }, + y: { signal: 'round(scale("y", datum.y2))' }, + width: { signal: 'round(scale("x", datum.x2)) - round(scale("x", datum.x1))' }, + height: { signal: 'round(scale("y", datum.y1)) - round(scale("y", datum.y2))' }, + aspect: { value: false }, + opacity: { value: 1 }, + }; + + // 1. Tissue image — data-driven (tiles supplied via the `data` prop). Each datum + // positions itself from its own extent (negative height → vertical flip to match + // the y-up plot), so streaming sharper tiles in is an in-place data update. if (config.showImage) { - marks = [ - { - type: 'image', - clip: true, - encode: { - update: { - url: { value: imageUrl }, - x: { signal: 'scale("x", 1)' }, // Use scale signal directly - y: { signal: `scale("y", ${imageHeight})` }, // Use "scale" function with y - width: { signal: `scale("x", ${imageWidth}) - scale("x", 1)` }, // Calculate width using scale domain - height: { signal: `scale("y", 0) - scale("y", ${imageHeight})` }, // Calculate height using scale domain - aspect: { value: false }, - opacity: { value: 1 }, - }, + marks.push({ + type: 'image', + clip: true, + from: { data: 'tissueImageData' }, + encode: { update: tileEncode }, + }); + } + + // 2a. Segmentation overlay — data-driven (image supplied via the `data` prop) + // so recolouring is an in-place dataset update, not a view rebuild. + if (hasSegmentation) { + marks.push({ + type: 'image', + clip: true, + from: { data: 'segOverlayData' }, + encode: { update: tileEncode }, + }); + } else { + // 2b. Centroid dots — permanent fallback when no segmentation zarr is available + marks.push({ + type: 'symbol', + clip: true, + from: { data: 'values' }, + encode: { + update: { + x: { scale: 'x', field: 'x' }, + y: { scale: 'y', field: 'flipped_y' }, + size: [{ value: config?.marker.size }], + stroke: config?.marker.outline + ? { scale: 'cellSetMarkColors', field: 'cellSetKey' } + : null, + fill: { scale: 'cellSetMarkColors', field: 'cellSetKey' }, + shape: { value: config?.marker.shape }, + fillOpacity: { value: config.marker.opacity / 10 }, }, }, - ...marks, - - ]; + }); } + // 3 + 4. Cluster labels and their background rects if (config?.labels.enabled) { - marks.push( - { - name: 'clusterLabels', - type: 'text', - clip: true, - from: { data: 'labels' }, - zindex: 1, - encode: { - update: { - x: { scale: 'x', field: 'medianX' }, - y: { scale: 'y', field: 'medianY' }, - text: { field: 'cellSetName' }, - fontSize: { value: config?.labels.size }, - strokeWidth: { value: 1.2 }, - fill: { value: config?.colour.masterColour }, - fillOpacity: { value: config?.labels.enabled }, - font: { value: config?.fontStyle.font }, - }, + marks.push({ + name: 'clusterLabels', + type: 'text', + clip: true, + from: { data: 'labels' }, + zindex: 1, + encode: { + update: { + x: { scale: 'x', field: 'medianX' }, + y: { scale: 'y', field: 'medianY' }, + text: { field: 'cellSetName' }, + fontSize: { value: config?.labels.size }, + strokeWidth: { value: 1.2 }, + fill: { value: config?.colour.masterColour }, + fillOpacity: { value: config?.labels.enabled }, + font: { value: config?.fontStyle.font }, }, - transform: [ - { - type: 'label', - size: { signal: '[width, height]' }, - anchor: ['left', 'right', 'top', 'bottom', 'middle'], - avoidBaseMark: false, - }, - ], }, - ); + transform: [{ + type: 'label', + size: { signal: '[width, height]' }, + anchor: ['left', 'right', 'top', 'bottom', 'middle'], + avoidBaseMark: false, + }], + }); - marks.push( - { - type: 'rect', - from: { data: 'clusterLabels' }, - encode: { - update: { - x: { field: 'bounds.x1', offset: -2 }, - x2: { field: 'bounds.x2', offset: 2 }, - y: { field: 'bounds.y1', offset: -2 }, - y2: { field: 'bounds.y2', offset: 2 }, - fill: { value: 'white' }, - opacity: { value: 0.5 }, - }, + marks.push({ + type: 'rect', + from: { data: 'clusterLabels' }, + encode: { + update: { + x: { field: 'bounds.x1', offset: -2 }, + x2: { field: 'bounds.x2', offset: 2 }, + y: { field: 'bounds.y1', offset: -2 }, + y2: { field: 'bounds.y2', offset: 2 }, + fill: { value: 'white' }, + opacity: { value: 0.5 }, }, }, - ); + }); } + // ── Axes ──────────────────────────────────────────────────────────────────── const axes = []; if (config.axes.xAxisLabels) { axes.push({ scale: 'x', grid: true, + // draw the axis (and its grid) above the tissue image and overlay + zindex: 1, domain: true, orient: 'bottom', + // keep edge labels within the axis range so a tick label entering/leaving the + // plot edge during pan/zoom doesn't change the padding (and bob the plot) + labelBound: true, + labelFlush: true, title: config.axes.xAxisText, titleFont: config.fontStyle.font, labelFont: config.fontStyle.font, @@ -190,7 +222,7 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = tickColor: config.colour.masterColour, gridColor: config.colour.masterColour, gridOpacity: (config.axes.gridOpacity / 20), - gridWidth: (config.gridWidth / 20), + gridWidth: (config.axes.gridWidth / 20), offset: config.axes.offset, titleFontSize: config.axes.titleFontSize, titleColor: config.colour.masterColour, @@ -204,9 +236,16 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = if (config.axes.yAxisLabels) { axes.push({ scale: 'y', - grid: false, + // horizontal gridlines (companion to the x-axis vertical gridlines) + grid: true, + // draw the axis (and its grid) above the tissue image and overlay + zindex: 1, domain: true, orient: 'left', + // keep edge labels within the axis range so a tick label entering/leaving the + // plot edge during pan/zoom doesn't change the padding (and bob the plot) + labelBound: true, + labelFlush: true, titlePadding: 5, gridColor: config.colour.masterColour, gridOpacity: (config.axes.gridOpacity / 20), @@ -224,26 +263,32 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = }); } + // FIXED padding + autosize:'none' so the data rectangle (= width x height) never + // changes. With 'pad'/'fit' Vega re-measures axis labels every render, so a tick + // label entering/leaving during zoom resizes the data rect and bobs the image. + // Here we reserve constant room for the axes/title/legend instead. + const legendPos = config.legend.enabled ? config.legend.position : null; + const padding = config.miniPlot ? 0 : { + left: (config.axes.yAxisLabels ? 54 : 8) + (legendPos === 'left' ? 150 : 0), + right: 8 + (legendPos && !['left', 'top', 'bottom'].includes(legendPos) ? 150 : 0), + top: (config.title?.text ? 28 : 8) + (legendPos === 'top' ? 56 : 0), + bottom: (config.axes.xAxisLabels ? (config.axes.xAxisRotateLabels ? 58 : 34) : 8) + + (legendPos === 'bottom' ? 64 : 0), + }; + return { $schema: 'https://vega.github.io/schema/vega/v5.json', - description: 'Continuous embedding plot', + description: 'Spatial categorical embedding plot', width: plotWidth, height: plotHeight, - autosize: { type: 'pad', resize: true }, - + autosize: { type: 'none' }, background: config.colour.toggleInvert, - padding: 5, + padding, data: [ { name: 'values', values: plotData, - // Vega internally modifies objects during data transforms. If the plot data is frozen, - // Vega is not able to carry out the transform and will throw an error. - // https://github.com/vega/vega/issues/2453#issuecomment-604516777 - format: { - type: 'json', - copy: true, - }, + format: { type: 'json', copy: true }, transform: [ { type: 'formula', @@ -257,18 +302,30 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = source: 'values', transform: [ { - type: 'aggregate', groupby: ['cellSetKey', 'cellSetName'], fields: ['x', 'flipped_y'], ops: ['median', 'median'], as: ['medianX', 'medianY'], + type: 'aggregate', + groupby: ['cellSetKey', 'cellSetName'], + fields: ['x', 'flipped_y'], + ops: ['median', 'median'], + as: ['medianX', 'medianY'], }, ], }, + // tissue + segmentation tiles ([{ url, x1, x2, y1, y2 }, …]), supplied/updated + // in place via the react-vega `data` prop + { name: 'tissueImageData', values: [] }, + { name: 'segOverlayData', values: [] }, ], + // clamp zoom/pan to the full image extent so you can always zoom back out to it + signals: spatialZoomSignals( + initXdom, initYdom, [0, imageWidth], [0, imageHeight], !config.miniPlot, + ), scales: [ { name: 'x', type: 'linear', nice: false, zero: false, - domain: xScaleDomain, + domain: { signal: 'xdom' }, range: 'width', }, { @@ -276,7 +333,7 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = type: 'linear', nice: false, zero: false, - domain: yScaleDomain, + domain: { signal: 'ydom' }, range: 'height', }, { @@ -300,8 +357,10 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = axes, marks, legends: legend, - title: - { + // Omit the title on mini previews — an (even empty) title reserves a line of + // vertical space, pushing the canvas taller than the fixed square tile and + // clipping the slide at the bottom. + title: config.miniPlot ? undefined : { text: config?.title.text, color: config.colour.masterColour, anchor: config.title.anchor, @@ -312,21 +371,19 @@ const generateSpec = (config, method, imageData, plotData, cellSetLegendsData) = }; }; +// ── filterCells, generateData — unchanged ────────────────────────────────────── + const filterCells = (cellSets, sampleKey, groupBy) => { let filteredCells = []; - // Get all the filtered cells if (sampleKey === 'All') { filteredCells = getAllCells(cellSets, groupBy); } else { filteredCells = getSampleCells(cellSets, sampleKey); } - // Get the cell set names const clusterEntries = cellSets.hierarchy - .find( - (rootNode) => rootNode.key === groupBy, - )?.children || []; + .find((rootNode) => rootNode.key === groupBy)?.children || []; const cellSetKeys = clusterEntries.map(({ key }) => key); @@ -337,7 +394,6 @@ const filterCells = (cellSets, sampleKey, groupBy) => { name: cellSets.properties[key].name, color: cellSets.properties[key].color, }); - return acc; }, []); @@ -348,8 +404,6 @@ const filterCells = (cellSets, sampleKey, groupBy) => { if (!cell) return acc; const inCellSet = colorToCellIdsMap.find((map) => map.cellIds.has(cell.cellId)); - - // If cell is not in the cell set, then return if (!inCellSet) return acc; const { key, name, color } = inCellSet; @@ -360,16 +414,11 @@ const filterCells = (cellSets, sampleKey, groupBy) => { } acc[cell.cellId] = { - ...cell, - cellSetKey: key, - cellSetName: name, - color, + ...cell, cellSetKey: key, cellSetName: name, color, }; - return acc; }, {}); - // Sort legends to show them in the order that cellSetKeys are stored cellSetLegendsData = _.sortBy( cellSetLegendsData, ({ key }) => _.indexOf(cellSetKeys, key), @@ -378,7 +427,6 @@ const filterCells = (cellSets, sampleKey, groupBy) => { return { filteredCells, cellSetLegendsData }; }; -// Generate dynamic data from redux store const generateData = (cellSets, sampleKey, groupBy, embeddingData) => { const { filteredCells, cellSetLegendsData } = filterCells(cellSets, sampleKey, groupBy); @@ -386,21 +434,13 @@ const generateData = (cellSets, sampleKey, groupBy, embeddingData) => { .map((coordinates, cellId) => ({ cellId, coordinates })) .filter(({ coordinates }) => coordinates !== undefined) .filter(({ cellId }) => Object.hasOwn(filteredCells, cellId)) - .map((data) => { - const { cellId, coordinates } = data; - - return { - ...filteredCells[cellId], - x: coordinates[0], - y: coordinates[1], - }; - }); + .map(({ cellId, coordinates }) => ({ + ...filteredCells[cellId], + x: coordinates[0], + y: coordinates[1], + })); return { plotData, cellSetLegendsData }; }; -export { - generateSpec, - generateData, - filterCells, -}; +export { generateSpec, generateData, filterCells }; diff --git a/src/utils/plotSpecs/generateSpatialFeatureSpec.js b/src/utils/plotSpecs/generateSpatialFeatureSpec.js index c1e948f651..354f8c95e3 100644 --- a/src/utils/plotSpecs/generateSpatialFeatureSpec.js +++ b/src/utils/plotSpecs/generateSpatialFeatureSpec.js @@ -1,94 +1,133 @@ /* eslint-disable no-param-reassign */ import { getAllCells, getSampleCells } from 'utils/cellSets'; +import spatialZoomSignals from 'utils/plotSpecs/spatialZoomSignals'; -const generateSpec = (config, method, imageData, plotData) => { - const { imageUrl, imageWidth, imageHeight } = imageData; - - const xScaleDomain = config.axesRanges.xAxisAuto - ? [0, imageWidth] - : [config.axesRanges.xMin, config.axesRanges.xMax]; +/** + * @param {object} config + * @param {string} method + * @param {object} imageData { imageWidth, imageHeight } — level-0 full dims + * for the Vega scale domains. The tissue tiles themselves stream in via the + * `data` prop (dataset 'tissueImageData'); see useSpatialStream. + * @param {Array} plotData + */ +// hasSegmentation controls whether the (data-driven) segmentation overlay mark or +// the centroid-dot fallback is rendered. Both the tissue tiles and the overlay are +// supplied at runtime through the Vega `data` prop ('tissueImageData' / +// 'segOverlayData'), so streaming sharper tiles + recolouring update the view in +// place without rebuilding it. +const generateSpec = ( + config, method, imageData, plotData, hasSegmentation = false, +) => { + const { imageWidth, imageHeight } = imageData; - const yScaleDomain = config.axesRanges.yAxisAuto - ? [0, imageHeight] - : [config.axesRanges.yMin, config.axesRanges.yMax]; + // Initial zoom/pan domains are ALWAYS the full image extent — the spec is + // intentionally invariant to config.axesRanges so that persisting a zoom never + // changes the spec CONTENT (react-vega's VegaEmbed rebuilds the view on an + // expensive spec change), which would flicker the slide. The persisted zoom + // (config.axesRanges) is re-applied imperatively after (re)build via the plot's + // onNewView (restoreZoom), by setting the initXdom/initYdom signals. + const initXdom = [0, imageWidth]; + const initYdom = [0, imageHeight]; + // Plot size always matches the containing box (config.dimensions) — the styling + // panel for the full plot, the fixed mini-preview tile (MiniPlot) for previews. + // The full image extent is mapped into that box via the x/y scale domains. const plotWidth = config.dimensions.width; const plotHeight = config.dimensions.height; let legend = []; if (config.legend.enabled) { - legend = [ - { - fill: 'color', - type: 'gradient', - orient: config.legend.position, - direction: ['left', 'right'].includes(config.legend.position) ? 'vertical' : 'horizontal', - title: config.shownGene, - labelColor: config.colour.masterColour, - titleColor: config.colour.masterColour, - symbolType: 'circle', - symbolSize: 100, - offset: 40, - }]; + legend = [{ + fill: 'color', + type: 'gradient', + orient: config.legend.position, + direction: ['left', 'right'].includes(config.legend.position) ? 'vertical' : 'horizontal', + title: config.shownGene, + labelColor: config.colour.masterColour, + titleColor: config.colour.masterColour, + titleFontSize: config.legend.titleFontSize, + labelFontSize: config.legend.labelFontSize, + symbolType: 'circle', + symbolSize: 100, + offset: 40, + }]; } - let marks = [{ - type: 'symbol', - clip: true, - from: { data: 'plotData' }, - encode: { - update: { - x: { scale: 'x', field: 'x' }, - y: { scale: 'y', field: 'flipped_y' }, - size: [ - { value: config?.marker.size }, - ], - stroke: config?.marker.outline ? { - scale: 'color', - field: 'value', - } : null, - fill: { - scale: 'color', - field: 'value', - }, - // TODO: make selectable (hexagon) - shape: { value: config?.marker.shape }, - fillOpacity: { value: config.marker.opacity / 10 }, - }, - }, - }]; + const marks = []; + + // Tile image marks (tissue + segmentation) share this encoding. Edges are SNAPPED + // to integer device pixels with round() so adjacent tiles share the exact same edge + // pixel — no sub-pixel blank seam between tiles, and no overlap either. + const tileEncode = { + url: { field: 'url' }, + x: { signal: 'round(scale("x", datum.x1))' }, + y: { signal: 'round(scale("y", datum.y2))' }, + width: { signal: 'round(scale("x", datum.x2)) - round(scale("x", datum.x1))' }, + height: { signal: 'round(scale("y", datum.y1)) - round(scale("y", datum.y2))' }, + aspect: { value: false }, + opacity: { value: 1 }, + }; + + // 1. Tissue image — data-driven (tiles supplied via the `data` prop). Each datum + // positions itself from its own extent, so streaming sharper tiles in is an + // in-place data update — never a view rebuild. if (config.showImage) { - marks = [ - { - type: 'image', - clip: true, - encode: { - update: { - url: { value: imageUrl }, - x: { signal: 'scale("x", 1)' }, // Use scale signal directly - y: { signal: `scale("y", ${imageHeight})` }, // Use "scale" function with y - width: { signal: `scale("x", ${imageWidth}) - scale("x", 1)` }, // Calculate width using scale domain - height: { signal: `scale("y", 0) - scale("y", ${imageHeight})` }, // Calculate height using scale domain - aspect: { value: false }, - opacity: { value: 1 }, - }, + marks.push({ + type: 'image', + clip: true, + from: { data: 'tissueImageData' }, + encode: { update: tileEncode }, + }); + } + + // 2a. Segmentation overlay — data-driven (image supplied via the `data` prop) + // so recolouring is an in-place dataset update, not a view rebuild. + if (hasSegmentation) { + marks.push({ + type: 'image', + clip: true, + from: { data: 'segOverlayData' }, + encode: { update: tileEncode }, + }); + } else { + // 2b. Centroid dots — permanent fallback when no segmentation zarr available + marks.push({ + type: 'symbol', + clip: true, + from: { data: 'plotData' }, + encode: { + update: { + x: { scale: 'x', field: 'x' }, + y: { scale: 'y', field: 'flipped_y' }, + size: [{ value: config?.marker.size }], + stroke: config?.marker.outline ? { scale: 'color', field: 'value' } : null, + fill: { scale: 'color', field: 'value' }, + shape: { value: config?.marker.shape }, + fillOpacity: { value: config.marker.opacity / 10 }, }, }, - ...marks, - - ]; + }); } const axes = []; - if (config.axes.xAxisLabels) { + // Mini previews render just the image + overlay (no axes/padding) so the whole + // thumbnail fits the fixed 92×92 tile exactly — matching the histogram preview, + // with no size snap and no clipping. + if (!config.miniPlot && config.axes.xAxisLabels) { axes.push({ scale: 'x', grid: true, + // draw the axis (and its grid) above the tissue image and overlay + zindex: 1, domain: true, orient: 'bottom', + // keep edge labels within the axis range so a tick label entering/leaving the + // plot edge during pan/zoom doesn't change the padding (and bob the plot) + labelBound: true, + labelFlush: true, title: config.axes.xAxisText, titleFont: config.fontStyle.font, labelFont: config.fontStyle.font, @@ -96,7 +135,7 @@ const generateSpec = (config, method, imageData, plotData) => { tickColor: config.colour.masterColour, gridColor: config.colour.masterColour, gridOpacity: (config.axes.gridOpacity / 20), - gridWidth: (config.gridWidth / 20), + gridWidth: (config.axes.gridWidth / 20), offset: config.axes.offset, titleFontSize: config.axes.titleFontSize, titleColor: config.colour.masterColour, @@ -107,12 +146,19 @@ const generateSpec = (config, method, imageData, plotData) => { }); } - if (config.axes.yAxisLabels) { + if (!config.miniPlot && config.axes.yAxisLabels) { axes.push({ scale: 'y', - grid: false, + // horizontal gridlines (companion to the x-axis vertical gridlines) + grid: true, + // draw the axis (and its grid) above the tissue image and overlay + zindex: 1, domain: true, orient: 'left', + // keep edge labels within the axis range so a tick label entering/leaving the + // plot edge during pan/zoom doesn't change the padding (and bob the plot) + labelBound: true, + labelFlush: true, titlePadding: 5, gridColor: config.colour.masterColour, gridOpacity: (config.axes.gridOpacity / 20), @@ -130,15 +176,27 @@ const generateSpec = (config, method, imageData, plotData) => { }); } + // FIXED padding + autosize:'none' so the data rectangle (= width x height) never + // changes. With 'pad'/'fit' Vega re-measures axis labels every render, so a tick + // label entering/leaving during zoom resizes the data rect and bobs the image. + // Here we reserve constant room for the axes/title/legend instead. + const legendPos = config.legend.enabled ? config.legend.position : null; + const padding = config.miniPlot ? 0 : { + left: (config.axes.yAxisLabels ? 54 : 8) + (legendPos === 'left' ? 150 : 0), + right: 8 + (legendPos && !['left', 'top', 'bottom'].includes(legendPos) ? 150 : 0), + top: (config.title?.text ? 28 : 8) + (legendPos === 'top' ? 56 : 0), + bottom: (config.axes.xAxisLabels ? (config.axes.xAxisRotateLabels ? 58 : 34) : 8) + + (legendPos === 'bottom' ? 64 : 0), + }; + return { $schema: 'https://vega.github.io/schema/vega/v5.json', description: 'Continuous embedding plot', width: plotWidth, height: plotHeight, - autosize: { type: 'pad', resize: true }, - + autosize: { type: 'none' }, background: config.colour.toggleInvert, - padding: 5, + padding, data: [ { name: 'plotData', @@ -146,10 +204,7 @@ const generateSpec = (config, method, imageData, plotData) => { // Vega internally modifies objects during data transforms. If the plot data is frozen, // Vega is not able to carry out the transform and will throw an error. // https://github.com/vega/vega/issues/2453#issuecomment-604516777 - format: { - type: 'json', - copy: true, - }, + format: { type: 'json', copy: true }, transform: [ { type: 'formula', @@ -158,14 +213,22 @@ const generateSpec = (config, method, imageData, plotData) => { }, ], }, + // tissue + segmentation tiles ([{ url, x1, x2, y1, y2 }, …]), supplied/updated + // in place via the react-vega `data` prop + { name: 'tissueImageData', values: [] }, + { name: 'segOverlayData', values: [] }, ], + // clamp zoom/pan to the full image extent so you can always zoom back out to it + signals: spatialZoomSignals( + initXdom, initYdom, [0, imageWidth], [0, imageHeight], !config.miniPlot, + ), scales: [ { name: 'x', type: 'linear', nice: false, zero: false, - domain: xScaleDomain, + domain: { signal: 'xdom' }, range: 'width', }, { @@ -173,7 +236,7 @@ const generateSpec = (config, method, imageData, plotData) => { type: 'linear', nice: false, zero: false, - domain: yScaleDomain, + domain: { signal: 'ydom' }, range: 'height', }, { @@ -186,14 +249,18 @@ const generateSpec = (config, method, imageData, plotData) => { count: 5, }, domain: { data: 'plotData', field: 'value' }, - reverse: config.colour.gradient === 'spectral' || config.colour.reverseCbar, + // spectral defaults to reversed; reverseCbar flips that (XOR), so an explicit + // reverseCbar actually reverses instead of being a no-op on spectral. + reverse: (config.colour.gradient === 'spectral') !== Boolean(config.colour.reverseCbar), }, ], axes, marks, legends: legend, - title: - { + // Omit the title on mini previews — an (even empty) title reserves a line of + // vertical space, pushing the canvas taller than the fixed square tile and + // clipping the slide at the bottom. + title: config.miniPlot ? undefined : { text: config.title.text, color: config.colour.masterColour, anchor: config.title.anchor, @@ -216,32 +283,18 @@ const filterCells = (cellSets, selectedSample) => { return new Set(filteredCells.map((cell) => cell.cellId)); }; -const generateData = ( - cellSets, - selectedSample, - plotData, - embeddingData, -) => { - const filteredCells = filterCells(cellSets, selectedSample, embeddingData); +const generateData = (cellSets, selectedSample, plotData, embeddingData) => { + const filteredCells = filterCells(cellSets, selectedSample); - const cells = embeddingData + return embeddingData .map((coordinates, cellId) => ({ cellId, coordinates })) .filter(({ coordinates }) => coordinates !== undefined) .filter(({ cellId }) => filteredCells.has(cellId)) - .map((data) => { - const { cellId, coordinates } = data; - - return { - x: coordinates[0], - y: coordinates[1], - value: plotData[cellId], - }; - }); - - return cells; + .map(({ cellId, coordinates }) => ({ + x: coordinates[0], + y: coordinates[1], + value: plotData[cellId], + })); }; -export { - generateSpec, - generateData, -}; +export { generateSpec, generateData, filterCells }; diff --git a/src/utils/plotSpecs/generateZscoreHistogram.js b/src/utils/plotSpecs/generateZscoreHistogram.js new file mode 100644 index 0000000000..2383ff7ebc --- /dev/null +++ b/src/utils/plotSpecs/generateZscoreHistogram.js @@ -0,0 +1,207 @@ +// Histogram of spatial local-outlier z-scores for a data-processing filter. +// Bars beyond the cutoff (per direction) are coloured as outliers and a dashed +// rule marks the threshold. Mirrors generateDoubletScoreHistogram. +// +// @param {object} config merged plot + filter config (reads cutoff, binStep, axes…) +// @param {Array} plotData array of { zscore } records +// @param {string} direction 'lower' | 'upper' — which tail is removed +const generateSpec = (config, plotData, direction = 'lower') => { + const cutoff = config.cutoff ?? 3; + + // z-scores are unbounded and can be negative — derive the domain from the data. + let minZ = -cutoff * 2; + let maxZ = cutoff * 2; + if (plotData?.length) { + minZ = Infinity; + maxZ = -Infinity; + plotData.forEach(({ zscore }) => { + if (zscore < minZ) minZ = zscore; + if (zscore > maxZ) maxZ = zscore; + }); + // make sure the cutoff rule is always visible within the domain + minZ = Math.min(minZ, -cutoff); + maxZ = Math.max(maxZ, cutoff); + } + const extent = [minZ, maxZ]; + + const generateStatus = direction === 'upper' + ? `(datum.bin1 > ${cutoff}) ? 'outlier' : 'kept'` + : `(datum.bin0 < ${-cutoff}) ? 'outlier' : 'kept'`; + + const ruleValue = direction === 'upper' ? cutoff : -cutoff; + + const xScaleDomain = config.axesRanges.xAxisAuto + ? extent + : [config.axesRanges.xMin, config.axesRanges.xMax]; + + const yScaleDomain = config.axesRanges.yAxisAuto + ? { data: 'binned', field: 'count' } + : [config.axesRanges.yMin, config.axesRanges.yMax]; + + const legendDirection = ['top', 'bottom'].includes(config.legend.position) + ? 'horizontal' + : 'vertical'; + + const legend = !config.legend.enabled ? null : [ + { + fill: 'color', + orient: config.legend.position, + direction: legendDirection, + title: 'Status', + padding: 4, + encode: { + labels: { + update: { + fontSize: { value: config.legend.labelFontSize || 11 }, + fill: { value: 'black' }, + }, + }, + symbols: { + update: { + stroke: { value: 'transparent' }, + }, + }, + }, + }]; + + return { + $schema: 'https://vega.github.io/schema/vega/v5.json', + width: config.dimensions.width, + height: config.dimensions.height, + autosize: { type: 'fit', resize: true }, + padding: 5, + data: [ + { + name: 'plotData', + values: plotData, + // Vega mutates objects during transforms; copy so frozen data doesn't error. + // https://github.com/vega/vega/issues/2453#issuecomment-604516777 + format: { type: 'json', copy: true }, + }, + { + name: 'binned', + source: 'plotData', + transform: [ + { + type: 'bin', + field: 'zscore', + extent, + step: config.binStep, + nice: false, + }, + { + type: 'aggregate', + key: 'bin0', + groupby: ['bin0', 'bin1'], + fields: ['bin0'], + ops: ['count'], + as: ['count'], + }, + { + type: 'formula', + as: 'status', + expr: generateStatus, + }, + ], + }, + ], + scales: [ + { + name: 'xscale', + type: 'linear', + range: 'width', + domain: xScaleDomain, + zero: false, + }, + { + name: 'yscale', + type: 'linear', + range: 'height', + domain: yScaleDomain, + nice: true, + zero: false, + }, + { + name: 'color', + type: 'ordinal', + range: ['#2f9e44', 'red'], + domain: ['kept', 'outlier'], + }, + ], + axes: [ + { + orient: 'bottom', + scale: 'xscale', + grid: true, + zindex: 1, + title: config.axes.xAxisText, + titleFont: config.fontStyle.font, + labelFont: config.fontStyle.font, + titleFontSize: config.axes.titleFontSize, + labelFontSize: config.axes.labelFontSize, + offset: config.axes.offset, + gridOpacity: config.axes.gridOpacity / 20, + labelAngle: config.axes.xAxisRotateLabels ? 45 : 0, + labelAlign: config.axes.xAxisRotateLabels ? 'left' : 'center', + labels: config.axes.xAxisLabels, + ticks: config.axes.xAxisLabels, + }, + { + orient: 'left', + scale: 'yscale', + tickCount: 5, + grid: true, + zindex: 1, + title: config.axes.yAxisText, + titleFont: config.fontStyle.font, + labelFont: config.fontStyle.font, + titleFontSize: config.axes.titleFontSize, + labelFontSize: config.axes.labelFontSize, + offset: config.axes.offset, + gridOpacity: config.axes.gridOpacity / 20, + labels: config.axes.yAxisLabels, + ticks: config.axes.yAxisLabels, + }, + ], + marks: [ + { + type: 'rect', + clip: true, + from: { data: 'binned' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin0' }, + x2: { scale: 'xscale', field: 'bin1' }, + y: { scale: 'yscale', field: 'count' }, + y2: { scale: 'yscale', value: 0 }, + fill: { scale: 'color', field: 'status' }, + }, + }, + }, + { + type: 'rule', + clip: true, + encode: { + update: { + x: { scale: 'xscale', value: ruleValue }, + y: { value: 0 }, + y2: { field: { group: 'height' } }, + strokeWidth: { value: 2 }, + strokeDash: { value: [8, 4] }, + stroke: { value: 'red' }, + }, + }, + }, + ], + legends: legend, + title: { + text: config.title.text, + anchor: config.title.anchor, + font: config.fontStyle.font, + dx: config.title.dx, + fontSize: config.title.fontSize, + }, + }; +}; + +export default generateSpec; diff --git a/src/utils/plotSpecs/spatialZoomSignals.js b/src/utils/plotSpecs/spatialZoomSignals.js new file mode 100644 index 0000000000..179bf1c3e6 --- /dev/null +++ b/src/utils/plotSpecs/spatialZoomSignals.js @@ -0,0 +1,137 @@ +// Vega signals that implement mouse wheel-zoom + drag-pan for the spatial plots +// by driving the x/y scale domains. The same approach is used by the Trajectory +// Analysis plot. Because the tissue image and segmentation overlay are positioned +// with scale("x", ...) / scale("y", ...) and clipped to the plot area, zooming the +// domains zooms the already-loaded full image in place — smooth, with no tile +// refetch (so no flashing, and zooming out never blanks the slide). +// +// `boundsX` / `boundsY` are the stable full extent of the loaded image. Domains are +// clamped to them (span-preserving), so the most you can zoom out is the full slide. +// +// `domUpdates` emits the new [xdom, ydom] so the component can persist the view +// state across spec regenerations (e.g. when colours change). + +// Span-preserving clamp of [lo, hi] into the [dom[0], dom[1]] bounds signal. +const clampWin = (lo, hi, dom) => { + const bLo = `${dom}[0]`; + const bHi = `${dom}[1]`; + const sp = `min((${hi}) - (${lo}), ${bHi} - ${bLo})`; + const start = `max(${bLo}, min((${lo}), ${bHi} - ${sp}))`; + return `[${start}, ${start} + ${sp}]`; +}; + +const spatialZoomSignals = (initXdom, initYdom, boundsX, boundsY, interactive = true) => { + // Non-interactive (mini previews): static domains with no wheel/drag handlers, so + // the thumbnail never zooms or pans. A no-op `domUpdates` signal is kept so the + // component's signalListener still attaches without "unrecognized signal" errors. + if (!interactive) { + return [ + { name: 'boundsX', value: boundsX }, + { name: 'boundsY', value: boundsY }, + { name: 'initXdom', value: initXdom }, + { name: 'initYdom', value: initYdom }, + { name: 'xdom', value: initXdom }, + { name: 'ydom', value: initYdom }, + { name: 'domUpdates' }, + ]; + } + + const xPanLo = 'xcur[0] + span(xcur) * delta[0] / width'; + const xPanHi = 'xcur[1] + span(xcur) * delta[0] / width'; + const yPanLo = 'ycur[0] + span(ycur) * delta[1] / height'; + const yPanHi = 'ycur[1] + span(ycur) * delta[1] / height'; + const xZoomLo = 'anchor[0] + (xdom[0] - anchor[0]) * zoom'; + const xZoomHi = 'anchor[0] + (xdom[1] - anchor[0]) * zoom'; + const yZoomLo = 'anchor[1] + (ydom[0] - anchor[1]) * zoom'; + const yZoomHi = 'anchor[1] + (ydom[1] - anchor[1]) * zoom'; + + const xPan = clampWin(xPanLo, xPanHi, 'boundsX'); + const xZoomE = clampWin(xZoomLo, xZoomHi, 'boundsX'); + const yPan = clampWin(yPanLo, yPanHi, 'boundsY'); + const yZoomE = clampWin(yZoomLo, yZoomHi, 'boundsY'); + + return [ + { name: 'boundsX', value: boundsX }, + { name: 'boundsY', value: boundsY }, + { name: 'initXdom', value: initXdom }, + { name: 'initYdom', value: initYdom }, + { + name: 'down', + value: null, + on: [ + { events: 'mousedown', update: 'xy()' }, + { events: 'mouseup', update: 'null' }, + ], + }, + { + name: 'xcur', + value: null, + on: [{ events: 'mousedown', update: 'slice(xdom)' }], + }, + { + name: 'ycur', + value: null, + on: [{ events: 'mousedown', update: 'slice(ydom)' }], + }, + { + name: 'delta', + value: [0, 0], + on: [ + { + events: [ + { + source: 'window', + type: 'mousemove', + between: [ + { type: 'mousedown' }, + { source: 'window', type: 'mouseup' }, + ], + }, + ], + update: 'down ? [down[0]-x(), y()-down[1]] : [0,0]', + }, + ], + }, + { + name: 'anchor', + value: [0, 0], + on: [{ events: 'wheel', update: "[invert('x', x()), invert('y', y())]" }], + }, + { + name: 'zoom', + value: 1, + on: [ + { + events: 'wheel!', + force: true, + update: 'pow(1.001, event.deltaY * pow(2, event.deltaMode))', + }, + ], + }, + { + name: 'xdom', + update: 'initXdom', + on: [ + { events: { signal: 'delta' }, update: xPan }, + { events: { signal: 'zoom' }, update: xZoomE }, + ], + }, + { + name: 'ydom', + update: 'initYdom', + on: [ + { events: { signal: 'delta' }, update: yPan }, + { events: { signal: 'zoom' }, update: yZoomE }, + ], + }, + { + name: 'domUpdates', + on: [ + { events: { signal: 'delta' }, update: `[${xPan}, ${yPan}]` }, + { events: { signal: 'zoom' }, update: `[${xZoomE}, ${yZoomE}]` }, + ], + }, + ]; +}; + +export default spatialZoomSignals; diff --git a/src/utils/plotUtils.js b/src/utils/plotUtils.js index c46c685522..33084729d7 100644 --- a/src/utils/plotUtils.js +++ b/src/utils/plotUtils.js @@ -64,61 +64,6 @@ const colorByGeneExpression = (truncatedExpression, colorInterpolator, min, max )); }; -const filterCentroidsData = (results, colors, hiddenCentroids) => { - // obsCentroidsIndex is the cell names - // data keys are positions from 0 to length (no missing) - // centroidColors is a map from cell name => color - let dataKey = 0; - const data = [{}, {}]; - const obsCentroidsIndex = []; - const centroidColors = new Map(); - - results.forEach((value, key) => { - if (hiddenCentroids.has(key)) { - return; - } - if (value.length !== 2) { - throw new Error('Unexpected number of embedding dimensions'); - } - - const [x, y] = value; - data[0][dataKey] = x; - data[1][dataKey] = y; - - centroidColors.set(key.toString(), colors[key]); - obsCentroidsIndex.push(key.toString()); - - dataKey += 1; - }); - - return { - obsCentroids: { data, shape: [data.length, obsCentroidsIndex.length] }, - obsCentroidsIndex, - centroidColors, - }; -}; - -const convertCentroidsData = (results) => { - const data = [{}, {}]; - const obsCentroidsIndex = []; - - results.forEach((value, key) => { - if (value.length !== 2) { - throw new Error('Unexpected number of embedding dimensions'); - } - const [x, y] = value; - data[0][key] = x; - data[1][key] = y; - - obsCentroidsIndex.push(key.toString()); - }); - - return { - obsCentroids: { data, shape: [data.length, results.length] }, - obsCentroidsIndex, - }; -}; - const convertCellsData = (results, hidden, properties) => { const data = [[], []]; const obsEmbeddingIndex = []; @@ -144,7 +89,7 @@ const convertCellsData = (results, hidden, properties) => { }; const offsetCentroids = (results, properties, sampleIds, perImageShape, gridShape) => { - const [imageWidth, imageHeight] = perImageShape; + const [imageHeight, imageWidth] = perImageShape; const numColumns = gridShape[1]; // Pre-calculate offsets for each sampleId @@ -157,33 +102,32 @@ const offsetCentroids = (results, properties, sampleIds, perImageShape, gridShap }; }); - // Map the results with pre-calculated offsets - const offsetResults = results.map(([x, y], key) => { - // Determine which sample this cell belongs to - const sampleId = sampleIds.find((id) => properties[id].cellIds.has(key)); - if (!sampleId) { - throw new Error(`Sample ID not found for cell ID: ${key}`); - } + // Build a sparse array indexed by cell id. Cells with no coordinates + // (filtered/QC-failed cells are null in the worker result, matching the + // standard embedding) are left as holes, so every consumer's forEach skips + // them automatically — same as filterPolygons/convertCellsData. + // Build a sparse array indexed by cell id. Filtered cells (absent from every + // cluster) are excluded by the consumers via cellsInAnyCluster, so we keep the + // behaviour identical to the image-driven path: every cell that maps to a + // sample gets an entry (filtered cells may be [NaN, NaN] and are skipped + // downstream). + const offsetResults = []; + results.forEach((coords, key) => { + if (!coords) return; + + const sampleId = sampleIds.find((id) => properties[id]?.cellIds?.has(key)); + if (sampleId === undefined) return; - // Determine the index of the sample in the sampleIds array const sampleIndex = sampleIds.indexOf(sampleId); - if (sampleIndex === -1) { - throw new Error(`Sample ID ${sampleId} not found in sampleIds`); - } - - // Retrieve pre-calculated offsets const { xOffset, yOffset } = sampleOffsets[sampleIndex]; - // Apply offsets - return [x + xOffset, y + yOffset]; + const [x, y] = coords; + offsetResults[key] = [x + xOffset, y + yOffset]; }); return offsetResults; }; -const updateStatus = () => { }; -const clearPleaseWait = () => { }; - const convertRange = (value, r1, r2) => { // prevent devision by zero if (r1[0] === r1[1]) return value; @@ -195,10 +139,6 @@ const convertRange = (value, r1, r2) => { export { renderCellSetColors, convertCellsData, - convertCentroidsData, - filterCentroidsData, - updateStatus, - clearPleaseWait, colorByGeneExpression, offsetCentroids, hexToRgb, diff --git a/src/utils/qcSteps.js b/src/utils/qcSteps.js index 1b769310c9..11c0294135 100644 --- a/src/utils/qcSteps.js +++ b/src/utils/qcSteps.js @@ -1,10 +1,15 @@ -// canonical ordering of QC steps +// canonical ordering of QC steps. +// Single-cell and spatial (Visium HD) filters never run together — the data +// processing page shows only the set matching the dataset's technology. const qcSteps = [ 'classifier', 'cellSizeDistribution', 'mitochondrialContent', 'numGenesVsNumUmis', 'doubletScores', + 'spatialUmiOutlier', + 'spatialNumGenesOutlier', + 'spatialMitoOutlier', 'dataIntegration', 'configureEmbedding', ]; @@ -21,6 +26,12 @@ const getUserFriendlyQCStepName = (step) => { return 'Number of genes vs UMIs filter'; case 'doubletScores': return 'Doublet filter'; + case 'spatialUmiOutlier': + return 'UMI filter'; + case 'spatialNumGenesOutlier': + return 'Number of genes filter'; + case 'spatialMitoOutlier': + return 'Mitochondrial content filter'; case 'dataIntegration': return 'Data integration'; case 'configureEmbedding': diff --git a/src/utils/sampleFileType.js b/src/utils/sampleFileType.js index 91f868d777..b52630a598 100644 --- a/src/utils/sampleFileType.js +++ b/src/utils/sampleFileType.js @@ -11,6 +11,15 @@ const sampleFileType = { FEATURES_PARSE: 'featuresParse', BARCODES_PARSE: 'barcodesParse', MATRIX_PARSE: 'matrixParse', + VISIUM_HD_FILTERED_FEATURE_CELL_MATRIX: 'visium_hd_filtered_feature_cell_matrix', + VISIUM_HD_CELL_SEGMENTATIONS: 'visium_hd_cell_segmentations', + VISIUM_HD_TISSUE_HIRES_IMAGE: 'visium_hd_tissue_hires_image', + VISIUM_HD_SCALEFACTORS_JSON: 'visium_hd_scalefactors_json', + XENIUM_CELL_FEATURE_MATRIX: 'xenium_cell_feature_matrix', + XENIUM_CELLS: 'xenium_cells', + XENIUM_CELL_BOUNDARIES: 'xenium_cell_boundaries', + XENIUM_TRANSCRIPTS: 'xenium_transcripts', + MOLECULES_BY_GENE: 'molecules_by_gene', }; const fileTypeToDisplay = { @@ -25,8 +34,28 @@ const fileTypeToDisplay = { [sampleFileType.FEATURES_PARSE]: 'all_genes.csv', [sampleFileType.BARCODES_PARSE]: 'cell_metadata.csv', [sampleFileType.MATRIX_PARSE]: 'count_matrix.mtx', + [sampleFileType.VISIUM_HD_FILTERED_FEATURE_CELL_MATRIX]: 'filtered_feature_cell_matrix.h5', + [sampleFileType.VISIUM_HD_CELL_SEGMENTATIONS]: 'cell_segmentations.geojson', + [sampleFileType.VISIUM_HD_TISSUE_HIRES_IMAGE]: 'tissue_hires_image.png', + [sampleFileType.VISIUM_HD_SCALEFACTORS_JSON]: 'scalefactors_json.json', + [sampleFileType.XENIUM_CELL_FEATURE_MATRIX]: 'cell_feature_matrix.h5', + [sampleFileType.XENIUM_CELLS]: 'cells.parquet', + [sampleFileType.XENIUM_CELL_BOUNDARIES]: 'cell_boundaries.parquet', + [sampleFileType.XENIUM_TRANSCRIPTS]: 'transcripts.parquet', + [sampleFileType.MOLECULES_BY_GENE]: 'molecules.bygene.zip', +}; + +const fileTypeColumnWidth = { + [sampleFileType.VISIUM_HD_FILTERED_FEATURE_CELL_MATRIX]: 240, + [sampleFileType.VISIUM_HD_CELL_SEGMENTATIONS]: 220, + [sampleFileType.VISIUM_HD_TISSUE_HIRES_IMAGE]: 195, + [sampleFileType.VISIUM_HD_SCALEFACTORS_JSON]: 185, + [sampleFileType.XENIUM_CELL_FEATURE_MATRIX]: 215, + [sampleFileType.XENIUM_CELLS]: 150, + [sampleFileType.XENIUM_CELL_BOUNDARIES]: 195, + [sampleFileType.XENIUM_TRANSCRIPTS]: 175, }; export default sampleFileType; -export { fileTypeToDisplay }; +export { fileTypeToDisplay, fileTypeColumnWidth }; diff --git a/src/utils/spatial/deckPlotChrome.js b/src/utils/spatial/deckPlotChrome.js new file mode 100644 index 0000000000..b7f37ffc76 --- /dev/null +++ b/src/utils/spatial/deckPlotChrome.js @@ -0,0 +1,185 @@ +/** + * Pure helpers for the axes / title / legend "chrome" drawn around the WebGL + * Spatial Molecules plot. The chrome is described once as a flat model of + * primitives (rects / lines / texts, in container pixels) and rendered three + * ways from the SAME model so they always match: + * • live — React SVG elements overlaying the deck.gl canvas + * • PNG — drawn onto a 2D canvas alongside the GL snapshot (drawChromeToCanvas) + * • SVG — serialised to an with the GL snapshot embedded (chromeToSvg) + * + * deck.gl has no built-in axes/legend/export, so this reproduces (as far as is + * reasonable for a GPU scatter) what the old Vega molecule plot gave for free. + */ + +// "Nice" rounded number for axis ticks (Heckbert's algorithm). +const niceNum = (range, round) => { + const safeRange = range > 0 ? range : 1; + const exp = Math.floor(Math.log10(safeRange)); + const frac = safeRange / 10 ** exp; + let nf; + if (round) { + if (frac < 1.5) nf = 1; + else if (frac < 3) nf = 2; + else if (frac < 7) nf = 5; + else nf = 10; + } else if (frac <= 1) nf = 1; + else if (frac <= 2) nf = 2; + else if (frac <= 5) nf = 5; + else nf = 10; + return nf * 10 ** exp; +}; + +/** + * Evenly-spaced "nice" tick values within [min, max]. + * @returns {number[]} + */ +export const niceTicks = (min, max, maxTicks = 6) => { + if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return []; + const step = niceNum(niceNum(max - min, false) / Math.max(1, maxTicks - 1), true); + if (!(step > 0)) return []; + const start = Math.ceil(min / step) * step; + const ticks = []; + for (let v = start; v <= max + step * 1e-6; v += step) { + // collapse FP noise (e.g. 0.30000000000000004) to a clean value + ticks.push(Number(v.toPrecision(12))); + } + return ticks; +}; + +// Compact tick label: integers for large magnitudes, ≤1 decimal otherwise. +export const formatTick = (v) => { + if (!Number.isFinite(v)) return ''; + if (Math.abs(v) >= 100 || Number.isInteger(v)) return String(Math.round(v)); + return String(Number(v.toFixed(1))); +}; + +// Perceived-luminance test so text/axes flip to white on a dark (inverted) plot. +export const isDarkColor = (hex) => { + if (typeof hex !== 'string') return false; + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!m) return false; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => parseInt(h, 16)); + return 0.299 * r + 0.587 * g + 0.114 * b < 128; +}; + +const escapeXml = (s) => String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + +const svgAnchor = (a) => (a === 'middle' || a === 'end' ? a : 'start'); + +/** + * Serialise a chrome model + the plot content to a standalone SVG string. + * + * The molecule points are written as VECTOR s (clipped to the plot rect) + * so the SVG stays crisp at any zoom — not a raster snapshot. An optional GL + * snapshot can still be embedded as a raster backdrop (imageHref/imageRect). + * + * @param {{rects?:object[], lines?:object[], texts?:object[]}} model chrome model + * @param {object} opts width/height, background, imageHref, imageRect, clipRect + * [x,y,w,h], points [{cx,cy,r,fill}], pointsOpacity + * @returns {string} + */ +export const chromeToSvg = (model, { + width, height, background, imageHref, imageRect, clipRect, points, pointsOpacity = 1, +}) => { + const parts = [ + ``, + ``, + ]; + if (imageHref && imageRect) { + const [ix, iy, iw, ih] = imageRect; + parts.push( + ``, + ); + } + if (points?.length && clipRect) { + const [cx, cy, cw, ch] = clipRect; + parts.push( + ``, + ``, + ); + points.forEach((p) => parts.push( + ``, + )); + parts.push(''); + } + (model.rects ?? []).forEach((r) => { + parts.push( + ``, + ); + }); + (model.lines ?? []).forEach((l) => { + parts.push( + ``, + ); + }); + (model.texts ?? []).forEach((t) => { + const transform = t.rotate ? ` transform="rotate(${t.rotate} ${t.x} ${t.y})"` : ''; + parts.push( + `${escapeXml(t.text)}`, + ); + }); + parts.push(''); + return parts.join(''); +}; + +const canvasBaseline = (b) => { + if (b === 'middle' || b === 'central') return 'middle'; + if (b === 'hanging' || b === 'text-before-edge') return 'top'; + return 'alphabetic'; +}; + +/** + * Draw a chrome model onto a 2D canvas context (for the PNG export). The GL + * snapshot + background are drawn by the caller first; this paints the chrome on + * top. `scale` multiplies all coordinates for a hi-DPI export. + */ +export const drawChromeToCanvas = (ctx, model, scale = 1) => { + (model.rects ?? []).forEach((r) => { + ctx.globalAlpha = r.opacity ?? 1; + ctx.fillStyle = r.fill; + ctx.fillRect(r.x * scale, r.y * scale, r.w * scale, r.h * scale); + if (r.stroke) { + ctx.globalAlpha = 1; + ctx.strokeStyle = r.stroke; + ctx.lineWidth = scale; + ctx.strokeRect(r.x * scale, r.y * scale, r.w * scale, r.h * scale); + } + }); + (model.lines ?? []).forEach((l) => { + ctx.globalAlpha = l.opacity ?? 1; + ctx.strokeStyle = l.stroke; + ctx.lineWidth = (l.width ?? 1) * scale; + ctx.beginPath(); + ctx.moveTo(l.x1 * scale, l.y1 * scale); + ctx.lineTo(l.x2 * scale, l.y2 * scale); + ctx.stroke(); + }); + (model.texts ?? []).forEach((t) => { + ctx.save(); + ctx.globalAlpha = 1; + ctx.fillStyle = t.color; + ctx.font = `${t.size * scale}px ${t.font ?? 'sans-serif'}`; + ctx.textAlign = svgAnchor(t.anchor); + ctx.textBaseline = canvasBaseline(t.baseline); + if (t.rotate) { + ctx.translate(t.x * scale, t.y * scale); + ctx.rotate((t.rotate * Math.PI) / 180); + ctx.fillText(t.text, 0, 0); + } else { + ctx.fillText(t.text, t.x * scale, t.y * scale); + } + ctx.restore(); + }); + ctx.globalAlpha = 1; +}; diff --git a/src/utils/spatial/loadMoleculeNodes.js b/src/utils/spatial/loadMoleculeNodes.js new file mode 100644 index 0000000000..f1c7b6c61c --- /dev/null +++ b/src/utils/spatial/loadMoleculeNodes.js @@ -0,0 +1,155 @@ +import { tableFromIPC } from 'apache-arrow'; +// Registers the ZSTD decoder so the per-gene tiles (ZSTD-compressed Arrow bodies) +// can be parsed; side-effect import, must load before any tile is read. +import './registerArrowZstd'; + +/** + * Shared molecule reader for Xenium transcript overlays. + * + * Reads the `molecules.bygene.zip` artifact (one Feather entry PER GENE + our + * meta.json dictionary) over HTTP byte-ranges via the existing ZipFileStore + * pattern, and returns the molecule points for the requested genes as flat typed + * arrays. + * + * This is the SINGLE piece of code shared by the deck.gl SpatialViewer overlay and + * the Vega Plots & Tables spatial plot. See phase3-molecule-format-contract.md for the + * binding format contract. + * + * The artifact is GENE-PARTITIONED: each gene's molecules live in their own + * `{feature_code}.feather` entry, so reading a few genes range-reads ONLY those + * entries (no spatial quadtree, no LOD, no scanning/discarding other genes). We + * only ever render a few genes at a time, and deck.gl renders that point count + * directly, so there is no level-of-detail step. + * + * Coordinates are in MICRONS, the same frame as the centroids/segmentation + * polygons (no y-flip in the deck.gl viewer — callers apply the same + * offsetCentroids grid translation molecules share with the cells). + */ + +// Per-store cache of the parsed meta.json (the gene dictionary), keyed by the +// store instance. Each artifact zip is opened once and its meta parsed once. +const storeCache = new WeakMap(); + +const ZIP_KEY_PREFIX = '/'; // ZipFileStore.get strips a single leading slash + +const getEntry = async (store, key) => { + const bytes = await store.get(`${ZIP_KEY_PREFIX}${key}`); + if (!bytes) throw new Error(`molecules artifact: missing entry "${key}"`); + return bytes; +}; + +// Per-store cache of PARSED per-gene typed-arrays, keyed by entry name, so +// re-selecting a gene (or re-rendering) doesn't re-range-read + re-`toArray()` it. +// Bounded FIFO — a panel has hundreds of genes but only a handful are ever loaded. +const GENE_CACHE_LIMIT = 64; +const geneCache = new WeakMap(); // store -> Map(entry -> { xs, ys, n }) + +const readGeneEntry = async (store, entry) => { + let perStore = geneCache.get(store); + if (!perStore) { perStore = new Map(); geneCache.set(store, perStore); } + if (perStore.has(entry)) return perStore.get(entry); + + const bytes = await getEntry(store, entry); + const table = tableFromIPC(bytes); + const parsed = { + xs: table.getChild('x').toArray(), + ys: table.getChild('y').toArray(), + n: table.numRows, + }; + + if (perStore.size >= GENE_CACHE_LIMIT) { + perStore.delete(perStore.keys().next().value); // evict oldest (FIFO) + } + perStore.set(entry, parsed); + return parsed; +}; + +/** + * Load + cache meta.json for a store (the gene dictionary + build metadata). + * @returns {Promise} the parsed meta.json + */ +const loadMeta = async (store) => { + if (storeCache.has(store)) return storeCache.get(store); + + const promise = (async () => { + const metaBytes = await getEntry(store, 'meta.json'); + return JSON.parse(new TextDecoder().decode(metaBytes)); + })(); + + storeCache.set(store, promise); + return promise; +}; + +/** + * Read the molecule points for a set of genes. + * + * @param {object} store ZipFileStore opened on the molecules.bygene.zip + * @param {object} opts + * @param {number[]} [opts.genes] feature_codes to load; omit for ALL genes + * @returns {Promise<{ x: Float32Array, y: Float32Array, featureCode: Int32Array, count: number }>} + */ +const loadMoleculeNodes = async (store, { genes } = {}) => { + const meta = await loadMeta(store); + const dict = meta.genes ?? []; + + // code -> entry filename (the contract bakes `entry` per gene; default to + // `${code}.feather` for resilience to an older artifact that omits it). + const entryByCode = new Map(dict.map((g) => [g.code, g.entry ?? `${g.code}.feather`])); + + // Which genes to load: the requested codes (that exist in the dictionary), or + // every gene when none are requested. + const codes = Array.isArray(genes) && genes.length + ? genes.filter((code) => entryByCode.has(code)) + : dict.map((g) => g.code); + + // Range-read + parse each gene's entry (cached), in parallel. + const chunks = await Promise.all(codes.map(async (code) => { + const { xs, ys, n } = await readGeneEntry(store, entryByCode.get(code)); + return { + code, xs, ys, n, + }; + })); + + // A row survives if both coordinates are finite (Phase-1 guard). The gene is the + // entry, so every surviving row carries that entry's feature_code. + let total = 0; + chunks.forEach(({ xs, ys, n }) => { + for (let i = 0; i < n; i += 1) { + if (Number.isFinite(xs[i]) && Number.isFinite(ys[i])) total += 1; + } + }); + + const x = new Float32Array(total); + const y = new Float32Array(total); + const featureCode = new Int32Array(total); + + let w = 0; + chunks.forEach(({ + code, xs, ys, n, + }) => { + for (let i = 0; i < n; i += 1) { + if (Number.isFinite(xs[i]) && Number.isFinite(ys[i])) { + x[w] = xs[i]; + y[w] = ys[i]; + featureCode[w] = code; + w += 1; + } + } + }); + + return { + x, y, featureCode, count: total, + }; +}; + +/** + * Load the meta dictionary (genes/colors + build metadata) for a artifact store. + * Cached per store. + * @returns {Promise} the parsed meta.json + */ +const loadMoleculeMeta = async (store) => loadMeta(store); + +export default loadMoleculeNodes; +export { + loadMoleculeMeta, +}; diff --git a/src/utils/spatial/moleculeColors.js b/src/utils/spatial/moleculeColors.js new file mode 100644 index 0000000000..12ebcf7de0 --- /dev/null +++ b/src/utils/spatial/moleculeColors.js @@ -0,0 +1,46 @@ +// Categorical palette for spatial-molecule genes. +// +// Colour is assigned in the UI (not baked by the pipeline). resolveGeneColors +// allocates the first available palette colour per selected gene, so a user can +// always override an individual gene via the colour picker. The Spatial Molecules +// plot caps selection at MAX_MOLECULE_GENES (15), well under the palette length. +export const MOLECULE_PALETTE = [ + '#e6194B', '#4363d8', '#000000', '#f032e6', '#3cb44b', + '#ffe119', '#f58231', '#42d4f4', '#fabed4', '#469990', + '#dcbeff', '#9A6324', '#fffac8', '#800000', '#aaffc3', +]; + +// Fallback when more genes are plotted than the palette has colours (shouldn't +// happen given MAX_MOLECULE_GENES < palette length, but keep it defined). +const FALLBACK_COLOUR = '#cccccc'; + +// Resolve a colour for every selected gene by taking the FIRST AVAILABLE palette +// colour (gap-fill), in selection order: +// - an existing colour (geneColors[gene], truthy) wins and reserves its slot — a +// persisted/seeded colour or an explicit picker override (null means "no +// colour", e.g. a removed gene, and is ignored); +// - every other gene takes the lowest-index palette colour not already in use. +// Because seeded colours are persisted (and so reserved), appending a gene never +// recolours the others; and when a gene is removed its colour is freed, so the next +// gene added reclaims it (first available). +export const resolveGeneColors = (selectedGenes = [], geneColors = {}) => { + const used = new Set(); + selectedGenes.forEach((gene) => { + if (geneColors[gene]) used.add(geneColors[gene]); + }); + + const colors = {}; + selectedGenes.forEach((gene) => { + if (geneColors[gene]) { + colors[gene] = geneColors[gene]; + return; + } + const next = MOLECULE_PALETTE.find((c) => !used.has(c)) ?? FALLBACK_COLOUR; + used.add(next); + colors[gene] = next; + }); + return colors; +}; + +// Maximum number of genes plotted at once in the Spatial Molecules plot. +export const MAX_MOLECULE_GENES = 15; diff --git a/src/utils/spatial/registerArrowZstd.js b/src/utils/spatial/registerArrowZstd.js new file mode 100644 index 0000000000..446e955d00 --- /dev/null +++ b/src/utils/spatial/registerArrowZstd.js @@ -0,0 +1,17 @@ +import { compressionRegistry, CompressionType } from 'apache-arrow'; +import { decompress } from 'fzstd'; + +// apache-arrow JS (>= 21.1.0) ships the IPC compression framework but registers +// NO codecs by default, so reading a ZSTD-compressed Arrow body throws +// ("Record batch compression not implemented" on older arrow; an unregistered-codec +// error on 21+) unless we register a decoder. The pipeline writes the per-gene +// molecule Feathers with a ZSTD body (handle_data.R upload_molecules_to_s3), so +// register fzstd as the ZSTD *decoder*. +// +// Decode-only (no encode): the registry runs its encode-validation only when a +// codec exposes an encode method, so omitting it registers cleanly — and the +// browser never writes Arrow, it only reads. Side-effect import: pulling in the +// molecule reader registers the codec before any tile is parsed. +compressionRegistry.set(CompressionType.ZSTD, { + decode: (bytes) => decompress(bytes), +}); diff --git a/src/utils/upload/fileInspector.js b/src/utils/upload/fileInspector.js index 64991f3a1d..527361dde1 100644 --- a/src/utils/upload/fileInspector.js +++ b/src/utils/upload/fileInspector.js @@ -41,6 +41,12 @@ const inspectFile = async (file, technology) => { } if (technology === sampleTech.PARSE) { // TODO: look into adding validation return inspectParseFile(file); + } if (technology === sampleTech.VISIUM_HD) { + // Treat as already compressed so the uploader skips gzipping + return Verdict.VALID_ZIPPED; + } if (technology === sampleTech.XENIUM) { + // .parquet / .h5 are already binary/compressed, so skip gzipping + return Verdict.VALID_ZIPPED; } return Verdict.INVALID_FORMAT; diff --git a/src/utils/upload/fileUploadUtils.js b/src/utils/upload/fileUploadUtils.js index a6fa109ecf..d1897b7d0d 100644 --- a/src/utils/upload/fileUploadUtils.js +++ b/src/utils/upload/fileUploadUtils.js @@ -10,17 +10,20 @@ const techNamesToDisplay = { [sampleTech['10X']]: '10X Chromium', [sampleTech.RHAPSODY]: 'BD Rhapsody', [sampleTech.SEURAT_OBJECT]: 'Seurat', - [sampleTech.SEURAT_SPATIAL_OBJECT]: 'Seurat - Spatial', [sampleTech.SCE_OBJECT]: 'SingleCellExperiment', [sampleTech.ANNDATA_OBJECT]: 'AnnData', [sampleTech.H5]: '10X Chromium - H5', [sampleTech.PARSE]: 'Parse Evercode WT', + [sampleTech.VISIUM_HD]: 'Visium HD', + [sampleTech.XENIUM]: 'Xenium', + [sampleTech.SEURAT_SPATIAL_OBJECT]: 'Seurat - Spatial', }; const techCategoryNames = { SINGLE_CELL_COUNT_MATRIX: 'SINGLE CELL - Sample Count Matrices', SINGLE_CELL_PREPROCESSED: 'SINGLE CELL - Preprocessed Object', SPATIAL_PREPROCESSED: 'SPATIAL - Preprocessed Object', + SPATIAL_COUNT_MATRIX: 'SPATIAL - Sample Count Matrices', }; const matchFileName = (fileName, fileNames) => { @@ -135,6 +138,93 @@ const fileUploadUtils = { getFilePathToDisplay: getFilePathToDisplayDefaultConstructor(sampleTech.H5), getFileSampleAndName: getFileSampleAndNameDefault, }, + [sampleTech.VISIUM_HD]: { + category: techCategoryNames.SPATIAL_COUNT_MATRIX, + acceptedFiles: new Set([ + 'filtered_feature_cell_matrix.h5', + 'cell_segmentations.geojson', + 'tissue_hires_image.png', + 'scalefactors_json.json', + ]), + inputInfo: [ + ['💡Only supports output of Space Ranger 4.0+'], + ['filtered_feature_cell_matrix.h5 - typically found in segmented_outputs/'], + ['cell_segmentations.geojson - typically found in segmented_outputs/'], + ['tissue_hires_image.png - typically found in segmented_outputs/spatial/'], + ['scalefactors_json.json - typically found in segmented_outputs/spatial/'], + ], + requiredFiles: [ + sampleFileType.VISIUM_HD_FILTERED_FEATURE_CELL_MATRIX, + sampleFileType.VISIUM_HD_CELL_SEGMENTATIONS, + sampleFileType.VISIUM_HD_TISSUE_HIRES_IMAGE, + sampleFileType.VISIUM_HD_SCALEFACTORS_JSON, + ], + fileUploadParagraphs: [ + 'For each sample, upload a folder containing the 4 required files. The folder\'s name will be used to name the sample in it. You can change this name later in Data Management.', + 'The required files for each sample are:', + ], + dropzoneText: 'Drag and drop folders here or click to browse.', + webkitdirectory: '', + isNameValid(fileName) { return matchFileName(fileName, this.acceptedFiles); }, + getCorrespondingType(fileName) { + const fileNameToType = { + 'filtered_feature_cell_matrix.h5': sampleFileType.VISIUM_HD_FILTERED_FEATURE_CELL_MATRIX, + 'cell_segmentations.geojson': sampleFileType.VISIUM_HD_CELL_SEGMENTATIONS, + 'tissue_hires_image.png': sampleFileType.VISIUM_HD_TISSUE_HIRES_IMAGE, + 'scalefactors_json.json': sampleFileType.VISIUM_HD_SCALEFACTORS_JSON, + }; + const allowedNames = Array.from(this.acceptedFiles); + const name = allowedNames.find((allowedName) => fileName.endsWith(allowedName)); + return fileNameToType[name]; + }, + filterFiles: filterFilesDefaultConstructor(sampleTech.VISIUM_HD), + getFileSampleAndName: getFileSampleAndNameDefault, + getFilePathToDisplay: getFilePathToDisplayDefaultConstructor(sampleTech.VISIUM_HD), + }, + [sampleTech.XENIUM]: { + category: techCategoryNames.SPATIAL_COUNT_MATRIX, + acceptedFiles: new Set([ + 'cell_feature_matrix.h5', + 'cells.parquet', + 'cell_boundaries.parquet', + 'transcripts.parquet', + ]), + inputInfo: [ + ['cell_feature_matrix.h5 - found in the Xenium output directory'], + ['cells.parquet - found in the Xenium output directory'], + ['cell_boundaries.parquet - found in the Xenium output directory'], + ['transcripts.parquet - found in the Xenium output directory'], + ], + requiredFiles: [ + sampleFileType.XENIUM_CELL_FEATURE_MATRIX, + sampleFileType.XENIUM_CELLS, + sampleFileType.XENIUM_CELL_BOUNDARIES, + sampleFileType.XENIUM_TRANSCRIPTS, + ], + fileUploadParagraphs: [ + 'For each sample, upload a folder containing the 4 required files. The folder\'s name will be used to name the sample in it. You can change this name later in Data Management.', + 'The required files for each sample are:', + ], + dropzoneText: 'Drag and drop folders here or click to browse.', + webkitdirectory: '', + isNameValid(fileName) { return matchFileName(fileName, this.acceptedFiles); }, + getCorrespondingType(fileName) { + const fileNameToType = { + 'cell_feature_matrix.h5': sampleFileType.XENIUM_CELL_FEATURE_MATRIX, + 'cells.parquet': sampleFileType.XENIUM_CELLS, + 'cell_boundaries.parquet': sampleFileType.XENIUM_CELL_BOUNDARIES, + 'transcripts.parquet': sampleFileType.XENIUM_TRANSCRIPTS, + }; + // Match the longest accepted suffix first so e.g. 'cell_boundaries.parquet' + // isn't shadowed by the shorter 'cells.parquet'/'transcripts.parquet'. + const allowedNames = Array.from(this.acceptedFiles).sort((a, b) => b.length - a.length); + const name = allowedNames.find((allowedName) => fileName.endsWith(allowedName)); + return fileNameToType[name]; + }, + filterFiles: filterFilesDefaultConstructor(sampleTech.XENIUM), + getFileSampleAndName: getFileSampleAndNameDefault, + getFilePathToDisplay: getFilePathToDisplayDefaultConstructor(sampleTech.XENIUM), + }, [sampleTech.RHAPSODY]: { category: techCategoryNames.SINGLE_CELL_COUNT_MATRIX, acceptedFiles: new Set(['expression_data.st', 'expression_data.st.gz']), diff --git a/src/utils/upload/sampleValidators.js b/src/utils/upload/sampleValidators.js index cb776b6527..b3b63614de 100644 --- a/src/utils/upload/sampleValidators.js +++ b/src/utils/upload/sampleValidators.js @@ -5,6 +5,7 @@ import validateRhapsody from 'utils/upload/validateRhapsody'; import validateObj2s from 'utils/upload/validateObj2s'; import validateH5 from 'utils/upload/validateH5'; import validateParse from 'utils/upload/validateParse'; +import validateXenium from 'utils/upload/validateXenium'; const sampleValidators = { [sampleTech['10X']]: validate10x, @@ -15,6 +16,10 @@ const sampleValidators = { [sampleTech.ANNDATA_OBJECT]: validateObj2s, [sampleTech.H5]: validateH5, [sampleTech.PARSE]: validateParse, + [sampleTech.VISIUM_HD]: validateObj2s, + // Xenium is an end-to-end count-matrix tech: verify the three input files are + // present and have valid HDF5/Parquet magic bytes. + [sampleTech.XENIUM]: validateXenium, }; export default sampleValidators; diff --git a/src/utils/upload/validateXenium.js b/src/utils/upload/validateXenium.js new file mode 100644 index 0000000000..e223a12599 --- /dev/null +++ b/src/utils/upload/validateXenium.js @@ -0,0 +1,72 @@ +import SampleValidationError from 'utils/errors/upload/SampleValidationError'; +import sampleFileType from 'utils/sampleFileType'; + +// Xenium uploads are four binary files with well-known magic numbers. These +// files are treated as already-compressed (uploaded as-is, not gzipped), so we +// read their raw bytes directly. We don't parse the full files in the browser — +// the pipeline does that — but a magic-byte check catches the common mistake of +// dragging the wrong file (e.g. a CSV) into a Xenium slot. + +// HDF5 signature: the first 8 bytes of every HDF5 file. +// See https://docs.hdfgroup.org/hdf5/develop/_f_m_t3.html (Format Signature) +const HDF5_MAGIC = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a]; + +// Apache Parquet files begin and end with the 4-byte "PAR1" marker. +const PARQUET_MAGIC = [0x50, 0x41, 0x52, 0x31]; // "PAR1" + +const errorMessages = { + missingFiles: (missingFiles) => `Incomplete sample: Sample does not contain "${missingFiles.join('" and "')}" file(s). Please include the file in the sample.`, + invalidH5: (name) => `Invalid ${name}: file is not a valid HDF5 (.h5) file.`, + invalidParquet: (name) => `Invalid ${name}: file is not a valid Parquet (.parquet) file.`, +}; + +const startsWith = (bytes, magic) => magic.every((byte, i) => bytes[i] === byte); + +const readBytes = async (fileObject, start, end) => { + const buffer = await fileObject.slice(start, end).arrayBuffer(); + return new Uint8Array(buffer); +}; + +const validateSampleCompleteness = (sampleFiles) => { + const missingFiles = []; + + if (!sampleFiles[sampleFileType.XENIUM_CELL_FEATURE_MATRIX]) missingFiles.push('cell_feature_matrix.h5'); + if (!sampleFiles[sampleFileType.XENIUM_CELLS]) missingFiles.push('cells.parquet'); + if (!sampleFiles[sampleFileType.XENIUM_CELL_BOUNDARIES]) missingFiles.push('cell_boundaries.parquet'); + if (!sampleFiles[sampleFileType.XENIUM_TRANSCRIPTS]) missingFiles.push('transcripts.parquet'); + + if (missingFiles.length) { + throw new SampleValidationError(errorMessages.missingFiles(missingFiles)); + } +}; + +const validateH5 = async (sampleFiles) => { + const { fileObject } = sampleFiles[sampleFileType.XENIUM_CELL_FEATURE_MATRIX]; + + const head = await readBytes(fileObject, 0, HDF5_MAGIC.length); + if (!startsWith(head, HDF5_MAGIC)) { + throw new SampleValidationError(errorMessages.invalidH5('cell_feature_matrix.h5')); + } +}; + +const validateParquet = async (sampleFile, name) => { + const { fileObject } = sampleFile; + + const head = await readBytes(fileObject, 0, PARQUET_MAGIC.length); + const tail = await readBytes(fileObject, fileObject.size - PARQUET_MAGIC.length, fileObject.size); + + if (!startsWith(head, PARQUET_MAGIC) || !startsWith(tail, PARQUET_MAGIC)) { + throw new SampleValidationError(errorMessages.invalidParquet(name)); + } +}; + +const validateXenium = async (sample) => { + validateSampleCompleteness(sample.files); + + await validateH5(sample.files); + await validateParquet(sample.files[sampleFileType.XENIUM_CELLS], 'cells.parquet'); + await validateParquet(sample.files[sampleFileType.XENIUM_CELL_BOUNDARIES], 'cell_boundaries.parquet'); + await validateParquet(sample.files[sampleFileType.XENIUM_TRANSCRIPTS], 'transcripts.parquet'); +}; + +export default validateXenium;