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