From f19d8e2ee6be71f0bbb7aaebf7f727698fa50eb1 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 19 Jun 2026 13:00:48 +0100 Subject: [PATCH 01/47] add spatialdata.js dependencies --- package.json | 4 + pnpm-lock.yaml | 423 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 427 insertions(+) diff --git a/package.json b/package.json index da225d228..a31640bc0 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,10 @@ "@radix-ui/react-radio-group": "^1.1.3", "@react-spring/web": "^10.0.3", "@slickgrid-universal/common": "10.6.0", + "@spatialdata/core": "npm:@spatialdata/core@next", + "@spatialdata/layers": "npm:@spatialdata/layers@next", + "@spatialdata/react": "npm:@spatialdata/react@next", + "@spatialdata/vis": "npm:@spatialdata/vis@next", "@tanstack/react-query": "^5.80.0", "@tanstack/react-virtual": "^3.11.1", "@turf/clone": "6.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1e31c498..bb6ed8462 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,18 @@ importers: '@slickgrid-universal/common': specifier: 10.6.0 version: 10.6.0 + '@spatialdata/core': + specifier: npm:@spatialdata/core@next + version: 0.2.0 + '@spatialdata/layers': + specifier: npm:@spatialdata/layers@next + version: 0.2.0(4eff8b0fade424b0171a85a804c7311a) + '@spatialdata/react': + specifier: npm:@spatialdata/react@next + version: 0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) + '@spatialdata/vis': + specifier: npm:@spatialdata/vis@next + version: 0.2.0(5fed9fb1d070481f396b27a4e9a53f69) '@tanstack/react-query': specifier: ^5.80.0 version: 5.80.6(react@19.2.6) @@ -760,6 +772,14 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@cornerstonejs/codec-openjpeg@1.3.0': + resolution: {integrity: sha512-hP8WAZ63AcaDYmHbBVTY04x424AglXsRHrI6VBdW4eTiJ76f0heWrEodT9Sb3sNwazzYuyMOIZNndJPeVSeHcw==} + engines: {node: '>=0.14'} + + '@cornerstonejs/codec-openjph@2.4.7': + resolution: {integrity: sha512-qvP4q4JDib7mi9r7LqKOwqz7YZ8gjtDX4ZCezeYf8+eb7MBXCz5uXAMeVF3yz9Axw4XiIMdB/pqXkm8tqCl13w==} + engines: {node: '>=0.14'} + '@csstools/color-helpers@5.0.2': resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} engines: {node: '>=18'} @@ -1218,6 +1238,14 @@ packages: '@excel-builder-vanilla/types@5.0.0': resolution: {integrity: sha512-k0qMo9+velvx2EJne0gwT1NacDRCIWdCHpwTu5hjuRKrr1BzKCiTqgBKUf9zL74HIU7dejoWl3wgguWrccTFXg==} + '@fideus-labs/fizarrita@1.4.1': + resolution: {integrity: sha512-71HTW4cTDeUnm2f3MU3352MMlpzUAFHhDmEajaASpUywVDe7RdY8ImjeXwRllKjvfPcukfz/aEmdPs67esSngA==} + peerDependencies: + zarrita: '>=0.6.0' + + '@fideus-labs/worker-pool@1.0.0': + resolution: {integrity: sha512-Mwx8fFKKpTUcEtR7w45MQJ/RnZFUPBtefEvGTZGlZMAVSfD7W61rpB8ZdgS4V0CXfppwcb90BF7F/aANWvupnA==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1627,6 +1655,9 @@ packages: '@petamoriken/float16@3.9.2': resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + '@petamoriken/float16@3.9.3': + resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2188,6 +2219,36 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@spatialdata/avivatorish@0.2.0': + resolution: {integrity: sha512-Dk9xR+RppJzDqExJC6IaHLSGZp81F6cLEFFEBUQViaCZe2yJW8kmZR3WpCaxa/6oc4yqNgJhEHSaUOqCsZZquA==} + peerDependencies: + react: '>=18 <20' + react-dom: '>=18 <20' + + '@spatialdata/core@0.2.0': + resolution: {integrity: sha512-Aa2My/6TYsDtJo67XKYN1VqqDz0zXSi+UcygeP95n8LRJe5Hq63kIZKDhvx+fEjVnCnCN+MIODmaRwbSN3+WvQ==} + + '@spatialdata/layers@0.2.0': + resolution: {integrity: sha512-3ZKEszulUZAOhYAbDSy4sCYhDHZ7CeInNrMyTnbiG5Qe1mU82idv34PU69l6PPo7hPH4abql0kr0Qj/wtVMPmQ==} + peerDependencies: + deck.gl: ~9.2.9 + + '@spatialdata/react@0.2.0': + resolution: {integrity: sha512-//dK4chOaTwF/tN2EgY8xoHVXc3rOW+GgxJ/rL335V3V00CW2Uves0Ik2GJzQ2VKZ/aetNmPyMw483VthiMopg==} + peerDependencies: + anndata.js: ^0.0.2 + react: '>=18 <20' + react-dom: '>=18 <20' + zarrita: ^0.7.1 + + '@spatialdata/vis@0.2.0': + resolution: {integrity: sha512-+8psXCWHAM170tlS+AQmeIJ+6O0Jmba4cmEy8Z7XEJTg2obuI+xOPmlcukAMz3Da9jJxKqHKwoa282+myDAi9Q==} + peerDependencies: + anndata.js: ^0.0.2 + react: '>=18 <20' + react-dom: '>=18 <20' + zarrita: ^0.7.1 + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2644,6 +2705,9 @@ packages: '@types/prop-types@15.7.13': resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + '@types/rbush@4.0.0': + resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2782,6 +2846,20 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + '@uidotdev/usehooks@2.4.1': + resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} + engines: {node: '>=16'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@uiw/react-json-view@2.0.0-alpha.39': + resolution: {integrity: sha512-D9MHNan56WhtdAsmjtE9x18YLY0JSMnh0a6Ji0/2sVXCF456ZVumYLdx2II7hLQOgRMa4QMaHloytpTUHxsFRw==} + peerDependencies: + '@babel/runtime': '>=7.10.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3243,6 +3321,9 @@ packages: '@zarrita/storage@0.1.3': resolution: {integrity: sha512-ZyCMYN3LuCNtKxro9876r/KyHyXV+ie2Bhk1qYsJR4Jp+sAjoVRRNNSJPsJxk64ZgFFezayO5S2hCu88/1Odwg==} + '@zarrita/storage@0.2.0': + resolution: {integrity: sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==} + '@zip.js/zip.js@2.8.23': resolution: {integrity: sha512-RB+RLnxPJFPrGvQ9rgO+4JOcsob6lD32OcF0QE0yg24oeW9q8KnTTNlugcDaIveEcCbclobJcZP+fLQ++sH0bw==} engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} @@ -3271,6 +3352,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + anndata.js@0.0.2: + resolution: {integrity: sha512-26Y9mW7Jho41ydD8loW0rpMKgANEzvcBVn8+QH/6vX5rS5DBs5wBDdtMKQxFGsgE7mLj4/xErIiJyISW8tlQww==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3298,6 +3382,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + apache-arrow@17.0.0: + resolution: {integrity: sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==} + hasBin: true + apache-arrow@21.1.0: resolution: {integrity: sha512-kQrYLxhC+NTVVZ4CCzGF6L/uPVOzJmD1T3XgbiUnP7oTeVFOFgEUu6IKNwCDkpFoBVqDKQivlX4RUFqqnWFlEA==} hasBin: true @@ -3314,6 +3402,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-back@3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + array-back@6.2.3: resolution: {integrity: sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==} engines: {node: '>=12.17'} @@ -3539,6 +3631,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + command-line-args@5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + command-line-args@6.0.2: resolution: {integrity: sha512-AIjYVxrV9X752LmPDLbVYv8aMCuHPSLZJXEo2qo/xJfv+NYhaZ4sMSF01rM+gHPaMgvPM0l5D/F+Qx+i2WfSmQ==} engines: {node: '>=12.20'} @@ -3987,6 +4083,9 @@ packages: earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -4187,6 +4286,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-replace@3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + find-replace@5.0.2: resolution: {integrity: sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q==} engines: {node: '>=14'} @@ -4207,6 +4310,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@24.12.23: + resolution: {integrity: sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==} + flatbuffers@25.9.23: resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} @@ -4267,6 +4373,14 @@ packages: resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==} engines: {node: '>=10.19'} + geotiff@2.1.4-beta.0: + resolution: {integrity: sha512-jb6SYvHMyiCqwqgGGLDAxtig9h1g6O+n1wEyNEE4QgVEXOItYaWrEgPg9SAnwdoZm2yx6DpFtilbGG65hvZgpQ==} + engines: {node: '>=10.19'} + + geotiff@3.0.5: + resolution: {integrity: sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==} + engines: {node: '>=10.19'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -5083,6 +5197,10 @@ packages: nouislider@15.8.1: resolution: {integrity: sha512-93TweAi8kqntHJSPiSWQ1o/uZ29VWOmal9YKb6KKGGlCkugaNfAupT7o1qTHqdJvNQ7S0su5rO6qRFCjP8fxtw==} + numcodecs@0.2.2: + resolution: {integrity: sha512-Y5K8mv80yb4MgVpcElBkUeMZqeE4TrovxRit/dTZvoRl6YkB6WEjY+fiUjGCblITnt3T3fmrDg8yRWu0gOLjhQ==} + engines: {node: '>=12'} + numcodecs@0.3.2: resolution: {integrity: sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==} @@ -5108,6 +5226,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ol@10.9.0: + resolution: {integrity: sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -5146,6 +5267,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parquet-wasm@0.6.1: + resolution: {integrity: sha512-wTM/9Y4EHny8i0qgcOlL9UHsTXftowwCqDsAD8axaZbHp0Opp3ue8oxexbzTVNhqBjFhyhLiU3MT0rnEYnYU0Q==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -5192,6 +5316,10 @@ packages: resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} hasBin: true + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + pdfmake@0.2.23: resolution: {integrity: sha512-A/IksoKb/ikOZH1edSDJ/2zBbqJKDghD4+fXn3rT7quvCJDlsZMs3NmIB3eajLMMFU9Bd3bZPVvlUMXhvFI+bQ==} engines: {node: '>=18'} @@ -5370,6 +5498,12 @@ packages: quickselect@2.0.0: resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + + rbush@4.0.1: + resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==} + react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -5881,6 +6015,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + typical@4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + typical@7.3.0: resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} engines: {node: '>=12.17'} @@ -5931,6 +6069,10 @@ packages: resolution: {integrity: sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==} engines: {node: '>=12'} + unzipit@2.0.0: + resolution: {integrity: sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==} + engines: {node: '>=18'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -6203,9 +6345,18 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zarrextra@0.2.0: + resolution: {integrity: sha512-dcos8Lvo3+GWqaWX8IA1i0wh6yXJSOGNuC3XFmIpjbkXrlDJfPZPWTOwZPJn2LRH0aZARmUlyxy+DgfVm+TJsQ==} + + zarrita@0.5.1: + resolution: {integrity: sha512-cyujP70BOl5DiXuLtM+0j9nq/pAov4SKXRYIQQOVnk2TfBg/jopX+FXLbqkq3ULOxFLB5AwkPbSp5KvZXoJrbQ==} + zarrita@0.5.4: resolution: {integrity: sha512-i88iN2+HqIQ+uiCEWLfhjbYNXAJD7IrM4h3lFwFclfqEOOhxp10amRWtqmgN5jbuy3+h0LwdyLVVzk4y9rTLgg==} + zarrita@0.7.3: + resolution: {integrity: sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -6215,12 +6366,36 @@ packages: zod@3.25.49: resolution: {integrity: sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zstd-codec@0.1.5: resolution: {integrity: sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==} zstddec@0.1.0: resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zustand@5.0.6: resolution: {integrity: sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==} engines: {node: '>=12.20.0'} @@ -6587,6 +6762,12 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@cornerstonejs/codec-openjpeg@1.3.0': + optional: true + + '@cornerstonejs/codec-openjph@2.4.7': + optional: true + '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -7142,6 +7323,15 @@ snapshots: dependencies: fflate: 0.8.2 + '@fideus-labs/fizarrita@1.4.1(zarrita@0.7.3)': + dependencies: + '@fideus-labs/worker-pool': 1.0.0 + zarrita: 0.7.3 + optional: true + + '@fideus-labs/worker-pool@1.0.0': + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -7760,6 +7950,8 @@ snapshots: '@petamoriken/float16@3.9.2': {} + '@petamoriken/float16@3.9.3': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -8215,6 +8407,106 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@spatialdata/avivatorish@0.2.0(b1235b24ca004e234da3e4c035d43e3a)': + dependencies: + '@hms-dbmi/viv': 0.21.0(b55db1092d89286dd86a223febb17f6d) + '@math.gl/core': 4.1.0 + geotiff: 2.1.4-beta.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + zarrextra: 0.2.0 + zustand: 5.0.14(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + transitivePeerDependencies: + - '@deck.gl/core' + - '@deck.gl/geo-layers' + - '@deck.gl/layers' + - '@deck.gl/react' + - '@luma.gl/constants' + - '@luma.gl/core' + - '@luma.gl/engine' + - '@luma.gl/shadertools' + - '@luma.gl/webgl' + - '@types/react' + - immer + - use-sync-external-store + + '@spatialdata/core@0.2.0': + dependencies: + '@math.gl/core': 4.1.0 + '@zarrita/storage': 0.2.0 + anndata.js: 0.0.2 + apache-arrow: 17.0.0 + ol: 10.9.0 + parquet-wasm: 0.6.1 + zarrextra: 0.2.0 + zarrita: 0.7.3 + zod: 4.4.3 + + '@spatialdata/layers@0.2.0(4eff8b0fade424b0171a85a804c7311a)': + dependencies: + '@deck.gl/core': 9.2.9 + '@hms-dbmi/viv': 0.21.0(b55db1092d89286dd86a223febb17f6d) + '@math.gl/core': 4.1.0 + deck.gl: 9.2.9(@arcgis/core@4.34.8)(@luma.gl/constants@9.2.6)(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))(@luma.gl/webgl@9.2.6(@luma.gl/core@9.2.6))(@math.gl/web-mercator@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + zod: 4.4.3 + transitivePeerDependencies: + - '@75lb/nature' + - '@deck.gl/geo-layers' + - '@deck.gl/layers' + - '@deck.gl/react' + - '@luma.gl/constants' + - '@luma.gl/core' + - '@luma.gl/engine' + - '@luma.gl/shadertools' + - '@luma.gl/webgl' + - react + + '@spatialdata/react@0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3)': + dependencies: + '@spatialdata/core': 0.2.0 + anndata.js: 0.0.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + zarrita: 0.7.3 + + '@spatialdata/vis@0.2.0(5fed9fb1d070481f396b27a4e9a53f69)': + dependencies: + '@deck.gl/core': 9.2.9 + '@hms-dbmi/viv': 0.21.0(b55db1092d89286dd86a223febb17f6d) + '@luma.gl/core': 9.2.6 + '@math.gl/core': 4.1.0 + '@spatialdata/avivatorish': 0.2.0(b1235b24ca004e234da3e4c035d43e3a) + '@spatialdata/core': 0.2.0 + '@spatialdata/layers': 0.2.0(4eff8b0fade424b0171a85a804c7311a) + '@spatialdata/react': 0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) + '@uidotdev/usehooks': 2.4.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@uiw/react-json-view': 2.0.0-alpha.39(@babel/runtime@7.29.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@vivjs/views': 0.21.0(52e8f89f20cb2eb0eb37426ecbefa25b) + anndata.js: 0.0.2 + deck.gl: 9.2.9(@arcgis/core@4.34.8)(@luma.gl/constants@9.2.6)(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))(@luma.gl/webgl@9.2.6(@luma.gl/core@9.2.6))(@math.gl/web-mercator@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + fast-deep-equal: 3.1.3 + geotiff: 2.1.4-beta.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + zarrita: 0.7.3 + zustand: 5.0.14(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + transitivePeerDependencies: + - '@75lb/nature' + - '@arcgis/core' + - '@babel/runtime' + - '@deck.gl/geo-layers' + - '@deck.gl/layers' + - '@deck.gl/react' + - '@luma.gl/constants' + - '@luma.gl/engine' + - '@luma.gl/gltf' + - '@luma.gl/shadertools' + - '@luma.gl/webgl' + - '@math.gl/web-mercator' + - '@types/react' + - immer + - use-sync-external-store + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.21': @@ -8895,6 +9187,8 @@ snapshots: '@types/prop-types@15.7.13': {} + '@types/rbush@4.0.0': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -9045,6 +9339,17 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260506.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260506.1 + '@uidotdev/usehooks@2.4.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@uiw/react-json-view@2.0.0-alpha.39(@babel/runtime@7.29.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -9684,6 +9989,11 @@ snapshots: reference-spec-reader: 0.2.0 unzipit: 1.4.3 + '@zarrita/storage@0.2.0': + dependencies: + reference-spec-reader: 0.2.0 + unzipit: 2.0.0 + '@zip.js/zip.js@2.8.23': {} '@zip.js/zip.js@2.8.26': {} @@ -9707,6 +10017,11 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + anndata.js@0.0.2: + dependencies: + numcodecs: 0.2.2 + zarrita: 0.5.1 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -9726,6 +10041,18 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + apache-arrow@17.0.0: + dependencies: + '@swc/helpers': 0.5.21 + '@types/command-line-args': 5.2.3 + '@types/command-line-usage': 5.0.4 + '@types/node': 20.19.37 + command-line-args: 5.2.1 + command-line-usage: 7.0.4 + flatbuffers: 24.12.23 + json-bignum: 0.0.3 + tslib: 2.8.1 + apache-arrow@21.1.0: dependencies: '@swc/helpers': 0.5.21 @@ -9752,6 +10079,8 @@ snapshots: dependencies: dequal: 2.0.3 + array-back@3.1.0: {} + array-back@6.2.3: {} assertion-error@2.0.1: {} @@ -9959,6 +10288,13 @@ snapshots: comma-separated-tokens@2.0.3: {} + command-line-args@5.2.1: + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + command-line-args@6.0.2: dependencies: array-back: 6.2.3 @@ -10429,6 +10765,8 @@ snapshots: earcut@2.2.4: {} + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.322: {} @@ -10665,6 +11003,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-replace@3.0.0: + dependencies: + array-back: 3.1.0 + find-replace@5.0.2: {} find-root@1.1.0: {} @@ -10679,6 +11021,8 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 + flatbuffers@24.12.23: {} + flatbuffers@25.9.23: {} flatpickr@4.6.13: {} @@ -10731,6 +11075,28 @@ snapshots: xml-utils: 1.10.2 zstddec: 0.1.0 + geotiff@2.1.4-beta.0: + dependencies: + '@petamoriken/float16': 3.9.2 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + + geotiff@3.0.5: + dependencies: + '@petamoriken/float16': 3.9.3 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + get-caller-file@2.0.5: {} get-east-asian-width@1.6.0: {} @@ -11767,6 +12133,8 @@ snapshots: nouislider@15.8.1: {} + numcodecs@0.2.2: {} + numcodecs@0.3.2: dependencies: fflate: 0.8.2 @@ -11786,6 +12154,15 @@ snapshots: obug@2.1.1: {} + ol@10.9.0: + dependencies: + '@types/rbush': 4.0.0 + earcut: 3.0.2 + geotiff: 3.0.5 + pbf: 4.0.1 + rbush: 4.0.1 + zarrita: 0.7.3 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -11828,6 +12205,8 @@ snapshots: dependencies: callsites: 3.1.0 + parquet-wasm@0.6.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -11875,6 +12254,10 @@ snapshots: ieee754: 1.2.1 resolve-protobuf-schema: 2.1.0 + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + pdfmake@0.2.23: dependencies: '@foliojs-fork/linebreak': 1.1.2 @@ -12033,6 +12416,12 @@ snapshots: quickselect@2.0.0: {} + quickselect@3.0.0: {} + + rbush@4.0.1: + dependencies: + quickselect: 3.0.0 + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 @@ -12656,6 +13045,8 @@ snapshots: typescript@6.0.3: {} + typical@4.0.0: {} + typical@7.3.0: {} uc.micro@2.1.0: {} @@ -12717,6 +13108,8 @@ snapshots: dependencies: uzip-module: 1.0.3 + unzipit@2.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -12910,22 +13303,52 @@ snapshots: yocto-queue@0.1.0: {} + zarrextra@0.2.0: + dependencies: + zarrita: 0.7.3 + zod: 4.4.3 + optionalDependencies: + '@cornerstonejs/codec-openjpeg': 1.3.0 + '@cornerstonejs/codec-openjph': 2.4.7 + '@fideus-labs/fizarrita': 1.4.1(zarrita@0.7.3) + '@fideus-labs/worker-pool': 1.0.0 + + zarrita@0.5.1: + dependencies: + '@zarrita/storage': 0.1.3 + numcodecs: 0.3.2 + zarrita@0.5.4: dependencies: '@zarrita/storage': 0.1.3 numcodecs: 0.3.2 + zarrita@0.7.3: + dependencies: + '@zarrita/storage': 0.2.0 + numcodecs: 0.3.2 + zod-validation-error@4.0.2(zod@3.25.49): dependencies: zod: 3.25.49 zod@3.25.49: {} + zod@4.4.3: {} + zstd-codec@0.1.5: optional: true zstddec@0.1.0: {} + zstddec@0.2.0: {} + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + zustand@5.0.6(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): optionalDependencies: '@types/react': 19.2.14 From 2300b25cfeb262364da4a5d480ab00d1b00a72ea Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 19 Jun 2026 13:10:13 +0100 Subject: [PATCH 02/47] initial implementation of spatial chart, dialog and related helpers --- CONTEXT.md | 14 +- docs/spatialdata-vis-integration.md | 33 ++ src/charts/registerChartModules.ts | 1 + src/react/components/SpatialDataMDVReact.tsx | 188 ++++++++ .../SpatialDataMDVReactComponent.tsx | 404 ++++++++++++++++ .../SpatialLayerDialogComponent.tsx | 430 ++++++++++++++++++ .../SpatialLayerDialogReactWrapper.tsx | 88 ++++ .../spatialLayers/DeckOverlayLayerPanel.tsx | 12 + .../spatialLayers/ImageLayerPanel.tsx | 291 ++++++++++++ .../spatialLayers/LabelsLayerPanel.tsx | 63 +++ .../spatialLayers/PointsLayerPanel.tsx | 52 +++ .../spatialLayers/ShapesLayerPanel.tsx | 163 +++++++ src/react/spatialdata/host_layer_resolver.ts | 14 + src/react/spatialdata/host_overlay_ids.ts | 36 ++ src/react/spatialdata/render_stack_display.ts | 21 + .../spatialdata/render_stack_mutations.ts | 87 ++++ src/react/spatialdata/render_stack_seed.ts | 156 +++++++ src/react/spatialdata/smoke_import.ts | 6 + .../spatialdata/spatial_feature_tooltip.ts | 33 ++ src/react/spatialdata/table_association.ts | 6 + src/react/spatialdata/view_state_bridge.ts | 30 ++ 21 files changed, 2127 insertions(+), 1 deletion(-) create mode 100644 docs/spatialdata-vis-integration.md create mode 100644 src/react/components/SpatialDataMDVReact.tsx create mode 100644 src/react/components/SpatialDataMDVReactComponent.tsx create mode 100644 src/react/components/SpatialLayerDialogComponent.tsx create mode 100644 src/react/components/SpatialLayerDialogReactWrapper.tsx create mode 100644 src/react/components/spatialLayers/DeckOverlayLayerPanel.tsx create mode 100644 src/react/components/spatialLayers/ImageLayerPanel.tsx create mode 100644 src/react/components/spatialLayers/LabelsLayerPanel.tsx create mode 100644 src/react/components/spatialLayers/PointsLayerPanel.tsx create mode 100644 src/react/components/spatialLayers/ShapesLayerPanel.tsx create mode 100644 src/react/spatialdata/host_layer_resolver.ts create mode 100644 src/react/spatialdata/host_overlay_ids.ts create mode 100644 src/react/spatialdata/render_stack_display.ts create mode 100644 src/react/spatialdata/render_stack_mutations.ts create mode 100644 src/react/spatialdata/render_stack_seed.ts create mode 100644 src/react/spatialdata/smoke_import.ts create mode 100644 src/react/spatialdata/spatial_feature_tooltip.ts create mode 100644 src/react/spatialdata/table_association.ts create mode 100644 src/react/spatialdata/view_state_bridge.ts diff --git a/CONTEXT.md b/CONTEXT.md index 84d0c7c39..84be77a5d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,4 +39,16 @@ detail, no decisions. Decisions that are hard to reverse live in `docs/adr/`. - **Installer / lockfile manager** — the tool that resolves, locks, and installs dependencies into an environment (e.g. `uv`, `pip`, `poetry`). Produces the lockfile - (`uv.lock`). Independent of the build backend. \ No newline at end of file + (`uv.lock`). Independent of the build backend. + +## SpatialData.js integration terms + +Canonical rendering vocabulary lives in [SpatialData.js CONTEXT.md](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/CONTEXT.md). MDV chart code uses the same terms: + +- **Render Stack** — saved draw order on chart config (`renderStack.entries`), not parallel `layerOrder` arrays +- **Stack Entry** — one ordered item (`kind: spatial | host | group`) +- **Host Overlay** — MDV deck layer referenced by `hostLayerId` in the stack, resolved at runtime +- **Runtime Attachment** — `hostLayerResolver`, tooltip hooks, `deckProps`; not serialized in `entry.props` +- **MobX Control Island** — layer dialog UI that patches `config.renderStack` directly + +See [docs/spatialdata-vis-integration.md](docs/spatialdata-vis-integration.md) for MDV-specific integration notes. \ No newline at end of file diff --git a/docs/spatialdata-vis-integration.md b/docs/spatialdata-vis-integration.md new file mode 100644 index 000000000..0ff29ad16 --- /dev/null +++ b/docs/spatialdata-vis-integration.md @@ -0,0 +1,33 @@ +# SpatialData.js integration notes (MDV) + +Living log for MDV ↔ `@spatialdata/*` integration. Architecture and phased roadmap: + +- [MDV integration](https://taylor-ccb-group.github.io/SpatialData.js/docs/vis/mdv-integration) +- [Headless viewer](https://taylor-ccb-group.github.io/SpatialData.js/docs/vis/headless-viewer) +- [MDV release checklist](https://taylor-ccb-group.github.io/SpatialData.js/docs/vis/mdv-release-checklist) + +## State model + +MDV owns `chart.config.renderStack` (MobX observable). `SpatialCanvasViewer` is a controlled renderer: plain `renderStack` + `hostLayerResolver` at the React boundary. No parallel stack maps or periodic whole-stack snapshots. + +## Initial PR scope + +| Commit stage | Status | +|--------------|--------| +| 1 | `@spatialdata/*@next` deps + CONTEXT + this doc | +| 2 | `SpatialDataMdvRegionReact` chart — `SpatialCanvasViewer` + host overlays | +| 3 | Layer dialog — `renderStack` list + dnd-kit reorder | +| 4 | Visibility + opacity per stack entry | +| 5+ | Type panels; shapes/labels/points added via dialog only | + +Implemented under `src/react/spatialdata/` and `src/react/components/SpatialData*`. + +## Deferred (follow-up PR) + +- Table-driven shape colouring (`fillColorByColumn`, `spatial_table_association`) +- `@spatialdata/avivatorish` zarr loader delegation (MDV keeps OME-TIFF local) +- Playwright fixture test + +## Divergence from prototype branch + +`codex/spatialdata.js_first_pass` used deprecated `layers`/`layerOrder` and `SpatialLayerStackConfig`. This worktree uses library `RenderStack` only. diff --git a/src/charts/registerChartModules.ts b/src/charts/registerChartModules.ts index 1f39996c4..7e505bbf3 100644 --- a/src/charts/registerChartModules.ts +++ b/src/charts/registerChartModules.ts @@ -32,6 +32,7 @@ import "./DeepToolsHeatMap"; // React-driven chart/dialog registrations. import "../react/components/VivMDVReact"; +import "../react/components/SpatialDataMDVReact"; import "../react/components/DeckScatterReactWrapper"; import "../react/components/SplatterPlotReactWrapper"; import "../charts/dialogs/AnnotationDialogReact"; diff --git a/src/react/components/SpatialDataMDVReact.tsx b/src/react/components/SpatialDataMDVReact.tsx new file mode 100644 index 000000000..a3382be24 --- /dev/null +++ b/src/react/components/SpatialDataMDVReact.tsx @@ -0,0 +1,188 @@ +import type { RenderStack } from "@spatialdata/layers"; +import { action, makeObservable, observable } from "mobx"; + +import BaseChart from "../../charts/BaseChart"; +import type DataStore from "@/datastore/DataStore"; +import { allNumeric } from "@/lib/columnTypeHelpers"; +import { g, toArray } from "@/lib/utils"; +import { scatterDefaults } from "../scatter_state"; +import { BaseReactChart } from "./BaseReactChart"; +import "../../charts/VivScatterPlot"; +import { + type VivContextType, + applyDefaultChannelState, + createVivStores, +} from "./avivatorish/state"; +import type { VivMdvReactConfig } from "./VivMDVReact"; +import { getSharedScatterSettings } from "./sharedScatterSettings"; +import SpatialLayerDialogReactWrapper from "./SpatialLayerDialogReactWrapper"; +import SpatialDataChartRoot from "./SpatialDataMDVReactComponent"; + +export type SpatialDataMdvReactConfig = VivMdvReactConfig & { + renderStack?: RenderStack; +}; + +function adaptSpatialDataConfig( + originalConfig: SpatialDataMdvReactConfig, + dataStore: DataStore, +) { + const config = { ...scatterDefaults, ...originalConfig }; + if (!dataStore.regions) { + throw new Error("unexpected attempt to load spatial chart with no regions in datasource"); + } + config.param = [...dataStore.regions.position_fields]; + if (typeof config.contourParameter === "string") { + const column = dataStore.columnIndex[config.contourParameter]; + if (!column || allNumeric([column])) { + config.contourParameter = dataStore.regions.region_field; + } + } + config.viv = applyDefaultChannelState(config.viv); + return config; +} + +class SpatialDataMdvReact extends BaseReactChart { + declare dataStore: DataStore; + vivStores: VivContextType; + layerDialog?: SpatialLayerDialogReactWrapper; + ignoreStateUpdate = false; + + get viewerStore() { + return this.vivStores?.viewerStore; + } + + constructor( + dataStore: DataStore, + div: HTMLDivElement, + originalConfig: SpatialDataMdvReactConfig, + ) { + const config = adaptSpatialDataConfig(originalConfig, dataStore); + super(dataStore, div, config, SpatialDataChartRoot); + this.colorByColumn(config.color_by); + makeObservable(this, { + colorBy: observable, + colorByColumn: action, + colorByDefault: action, + }); + this.vivStores = createVivStores(); + this.addMenuIcon("fas fa-layer-group", "Manage Layers").addEventListener( + "click", + () => { + if (!this.layerDialog) { + this.layerDialog = new SpatialLayerDialogReactWrapper(this); + this.dialogs.push(this.layerDialog); + } + }, + ); + } + + colorBy?: (i: number) => [r: number, g: number, b: number]; + + colorByColumn(col?: VivMdvReactConfig["color_by"]) { + if (!col) return this.colorByDefault(); + this.config.color_by = col; + //@ts-expect-error legacy color_by options are normalised at runtime by BaseChart. + this.colorBy = this.getColorFunction(col, true); + } + + colorByDefault() { + this.config.color_by = undefined; + this.colorBy = undefined; + } + + getColorOptions() { + return { + colorby: "all", + }; + } + + getSettings() { + const config = this.config; + const settings = super.getSettings(); + const filters = config.category_filters.map((filter) => { + const values = this.dataStore.columnIndex[filter.column]?.values?.slice(); + if (!values) throw `failed assertion that we should have a categorical '${filter.column}' here`; + values.unshift("all"); + return g({ + type: "multidropdown", + label: `'${filter.column}' filter`, + current_value: toArray(filter.category), + values: [values], + func: (value) => { + filter.category = value; + config.category_filters = config.category_filters.slice(); + }, + }); + }); + const dataStore = this.dataStore; + const imageRegionKeys = Object.keys(dataStore.regions?.all_regions ?? {}).filter( + (regionKey) => dataStore.regions?.all_regions[regionKey].spatial, + ); + const images = imageRegionKeys.map((regionKey) => ({ + name: regionKey, + value: regionKey, + })); + + return settings.concat([ + g({ + type: "dropdown", + label: `SpatialData (${dataStore.getColumnName(dataStore.regions?.region_field)})`, + current_value: config.region, + values: [images, "name", "value"], + func(value) { + if (config.title === config.region) { + config.title = value; + } + config.region = value; + config.background_filter.category = value; + }, + }), + ...getSharedScatterSettings(config, { + chart: this, + includeDensitySettings: true, + includePointShape: true, + }), + g({ + type: "folder", + label: "Category Filters", + current_value: filters, + }), + ]); + } + + getConfig() { + const config = super.getConfig(); + if (this.vivStores) { + const viewer = this.vivStores.viewerStore.getState(); + config.viv = { + ...config.viv, + viewerStore: { + viewState: viewer.viewState + ? { + target: viewer.viewState.target, + zoom: viewer.viewState.zoom, + } + : null, + }, + }; + } + if (this.config.renderStack) { + config.renderStack = this.config.renderStack; + } + return config; + } +} + +BaseChart.types.SpatialDataMdvRegionReact = { + ...BaseChart.types.VivMdvRegionReact, + init: (config, dataStore, extraConfig) => { + BaseChart.types.VivMdvRegionReact.init?.(config, dataStore, extraConfig); + config.type = "SpatialDataMdvRegionReact"; + }, + class: SpatialDataMdvReact, + name: "SpatialData.js Image Viewer (experimental)", +}; + +export { SpatialDataMdvReact }; +export type SpatialDataMdvReactType = typeof SpatialDataMdvReact; +export default SpatialDataMdvReact; diff --git a/src/react/components/SpatialDataMDVReactComponent.tsx b/src/react/components/SpatialDataMDVReactComponent.tsx new file mode 100644 index 000000000..1b9887fd8 --- /dev/null +++ b/src/react/components/SpatialDataMDVReactComponent.tsx @@ -0,0 +1,404 @@ +import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; +import { SpatialDataProvider, useSpatialData } from "@spatialdata/react"; +import { + SpatialCanvasViewer, + type SpatialFeaturePickEvent, + type ViewState as SpatialCanvasViewState, +} from "@spatialdata/vis"; +import type { DeckGLProps, OrbitViewState, OrthographicViewState, PickingInfo } from "deck.gl"; +import { observer } from "mobx-react-lite"; +import { runInAction } from "mobx"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { getProjectURL } from "@/dataloaders/DataLoaderUtil"; +import { getCombinedScatterTooltip } from "@/lib/scatterTooltip"; +import type { FieldName } from "@/charts/charts"; +import { + createMdvHostLayerResolver, + type MdvDeckOverlayLayers, +} from "@/react/spatialdata/host_layer_resolver"; +import { + createDefaultRenderStack, + normalizeRenderStack, +} from "@/react/spatialdata/render_stack_seed"; +import { toMdvViewState, toSpatialViewState } from "@/react/spatialdata/view_state_bridge"; +import { formatSpatialFeatureTooltipHtml } from "@/react/spatialdata/spatial_feature_tooltip"; +import { useOuterContainer } from "../screen_state"; +import { useViewStateLink } from "../chartLinkHooks"; +import { useChart } from "../context"; +import { useChartID, useChartSize, useConfig, useRegion } from "../hooks"; +import useGateLayers from "../hooks/useGateLayers"; +import { useOuterContainerDeckTooltip } from "../hooks/useOuterContainerDeckTooltip"; +import { SpatialAnnotationProvider, useSpatialLayers } from "../spatial_context"; +import { useFieldContourLegend } from "../contour_state"; +import type { DualContourLegacyConfig } from "../contour_state"; +import { + VivProvider, + useViewerStore, + useViewerStoreApi, +} from "./avivatorish/state"; +import FieldContourLegend from "./FieldContourLegend"; +import SelectionOverlay from "./SelectionOverlay"; +import type { SpatialDataMdvReact, SpatialDataMdvReactConfig } from "./SpatialDataMDVReact"; + +type SpatialRegionMetadata = { + spatial?: { + file?: string; + coordinate_system?: string; + }; +}; + +function getSpatialRegionMetadata(region: unknown): SpatialRegionMetadata | null { + if (!region || typeof region !== "object") return null; + return region; +} + +function getSpatialDataUrl(region: SpatialRegionMetadata) { + const file = region.spatial?.file; + if (!file) return null; + return getProjectURL(`spatial/${file}`); +} + +function cloneRenderStackEntry(entry: RenderStackEntry): RenderStackEntry { + if (entry.kind === "group") { + return { + ...entry, + props: { ...entry.props }, + children: [...entry.children], + }; + } + if (entry.kind === "host") { + return { + ...entry, + props: { ...entry.props }, + source: { ...entry.source }, + }; + } + return { + ...entry, + props: { ...entry.props }, + source: { ...entry.source }, + }; +} + +function toPlainRenderStack(stack: RenderStack | undefined): RenderStack | undefined { + if (!stack) return undefined; + return { + schemaVersion: stack.schemaVersion, + entries: stack.entries.map(cloneRenderStackEntry), + }; +} + +function SpatialDataChartRoot() { + const chart = useChart(); + const { vivStores } = chart; + return ( + + + + ); +} + +function SpatialDataLink() { + const { spatialData } = useSpatialData(); + const spatialDataUrl = spatialData?.url; + if (!spatialDataUrl) return null; + const demoUrl = `https://taylor-ccb-group.github.io/SpatialData.js/docs/demo/?url=${encodeURIComponent(spatialDataUrl)}`; + return ( +
+ + Open in spatialdata.js + +
+ ); +} + +const SpatialDataMainChart = observer(() => { + const chart = useChart(); + const [hoveredField, setHoveredField] = useState(null); + const rawRegion = useRegion(); + const region = getSpatialRegionMetadata(rawRegion); + const spatialDataUrl = region ? getSpatialDataUrl(region) : null; + + return ( + + + + + + ); +}); + +const SpatialDataViewer = observer( + ({ + setHoveredField, + }: { + setHoveredField: (fieldId: FieldName | null) => void; + }) => { + const chart = useChart(); + const config = useConfig(); + const rawRegion = useRegion(); + const region = getSpatialRegionMetadata(rawRegion); + const coordinateSystem = region?.spatial?.coordinate_system ?? null; + const { spatialData, loading, error } = useSpatialData(); + const viewerStore = useViewerStoreApi(); + const viewState = useViewerStore((store) => store.viewState); + const spatialViewState = toSpatialViewState(viewState); + const [width, height] = useChartSize(); + const id = useChartID(); + const deckContainerRef = useRef(null); + const stackSeededRef = useRef(null); + const { scatterProps, selectionLayer } = useSpatialLayers(); + const { + scatterplotLayer, + greyScatterplotLayer, + getTooltip, + setScatterKeyboardActive, + } = scatterProps; + const { gateLabelLayer, gateDisplayLayer, controllerOptions } = useGateLayers(); + const contourConfig = useConfig(); + const legendFields = useFieldContourLegend(contourConfig.densityFields); + const showLegend = contourConfig.field_legend.display; + const legendPosition = { x: 10, y: 10 }; + + useViewStateLink(); + + useEffect(() => { + const saved = config.viv?.viewerStore?.viewState; + if (!saved || viewState) return; + viewerStore.setState({ + viewState: { + target: [...saved.target], + zoom: saved.zoom, + } as OrthographicViewState, + }); + }, [config.viv?.viewerStore?.viewState, viewState, viewerStore]); + + useEffect(() => { + if (scatterProps.viewState) { + viewerStore.setState({ viewState: scatterProps.viewState }); + } + }, [scatterProps.viewState, viewerStore]); + + useEffect(() => { + if (loading || !spatialData || !coordinateSystem) return; + const seedKey = `${region?.spatial?.file ?? ""}:${coordinateSystem}`; + if (stackSeededRef.current === seedKey && config.renderStack?.entries.length) { + return; + } + stackSeededRef.current = seedKey; + runInAction(() => { + const next = + !config.renderStack?.entries.length + ? createDefaultRenderStack(spatialData, coordinateSystem) + : normalizeRenderStack(config.renderStack, spatialData, coordinateSystem); + config.renderStack = next; + }); + }, [config, coordinateSystem, loading, region?.spatial?.file, spatialData]); + + // Dialog mutations patch `config.renderStack` in place (same object reference). + // Read nested entry fields during render so MobX re-runs this observer, then pass + // a fresh plain copy into SpatialCanvasViewer on every tracked change. + const renderStackEntries = config.renderStack?.entries; + renderStackEntries?.forEach((entry) => { + void entry.id; + void entry.visible; + void entry.props; + }); + const renderStack = toPlainRenderStack(config.renderStack); + + const deckOverlaySources = useMemo( + () => ({ + grey_scatter: greyScatterplotLayer, + scatter: scatterplotLayer, + gate_display: gateDisplayLayer, + selection: selectionLayer, + gate_labels: gateLabelLayer, + }), + [ + gateLabelLayer, + gateDisplayLayer, + scatterplotLayer, + greyScatterplotLayer, + selectionLayer, + ], + ); + + const hostLayerResolver = useMemo( + () => createMdvHostLayerResolver(deckOverlaySources), + [deckOverlaySources], + ); + + const onSpatialViewStateChange = useCallback( + (next: SpatialCanvasViewState) => { + viewerStore.setState({ + viewState: toMdvViewState(next, viewState), + }); + }, + [viewerStore, viewState], + ); + + const getTooltipContent = useCallback( + (info: PickingInfo) => { + return getCombinedScatterTooltip(info, { + gateDisplayLayerId: gateDisplayLayer?.id, + gateLabelLayerId: gateLabelLayer?.id, + getPointTooltip: getTooltip, + }); + }, + [gateDisplayLayer?.id, gateLabelLayer?.id, getTooltip], + ); + + const { + clearTooltip, + getTooltip: getPortalTooltip, + suppressTooltipUntilPointerUp, + tooltipPortal, + } = useOuterContainerDeckTooltip(getTooltipContent, deckContainerRef); + const outerContainer = useOuterContainer(); + const [featureTooltip, setFeatureTooltip] = useState<{ + html: string; + x: number; + y: number; + } | null>(null); + + const onFeatureHover = useCallback( + (event: SpatialFeaturePickEvent) => { + const info = event.pickInfo; + if (!event.tooltip || !Number.isFinite(info.x) || !Number.isFinite(info.y)) { + setFeatureTooltip(null); + return; + } + const anchor = deckContainerRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + setFeatureTooltip({ + html: formatSpatialFeatureTooltipHtml(event.tooltip), + x: rect.left + info.x, + y: rect.top + info.y, + }); + }, + [], + ); + + const featureTooltipPortal = + featureTooltip && outerContainer + ? createPortal( +
, + outerContainer, + ) + : null; + + const deckProps: Partial = useMemo( + () => ({ + getTooltip: getPortalTooltip, + id: `${id}spatialdata-deck`, + controller: { + doubleClickZoom: false, + dragPan: controllerOptions.dragPan, + }, + }), + [id, getPortalTooltip, controllerOptions], + ); + + if (!coordinateSystem || !region?.spatial?.file) { + return ( +
+ SpatialData.js viewer requires region.spatial.file and + region.spatial.coordinate_system metadata. +
+ ); + } + + if (error) { + return ( +
+ Failed to load SpatialData store: {error.message} +
+ ); + } + + if (loading || !spatialData) { + return
Loading SpatialData store…
; + } + + if (!renderStack?.entries.length) { + return
Preparing layer stack…
; + } + + return ( + <> + + {showLegend && legendFields.length > 0 && ( + + )} +
{ + setScatterKeyboardActive(true); + }} + onMouseEnter={() => setScatterKeyboardActive(true)} + onMouseLeave={() => { + clearTooltip(); + setFeatureTooltip(null); + setScatterKeyboardActive(false); + }} + > +
+ +
+
+ + {tooltipPortal} + {featureTooltipPortal} + + ); + }, +); + +export default SpatialDataChartRoot; diff --git a/src/react/components/SpatialLayerDialogComponent.tsx b/src/react/components/SpatialLayerDialogComponent.tsx new file mode 100644 index 000000000..6ad2d07d4 --- /dev/null +++ b/src/react/components/SpatialLayerDialogComponent.tsx @@ -0,0 +1,430 @@ +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import HighlightOffIcon from "@mui/icons-material/HighlightOff"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Autocomplete, + Checkbox, + IconButton, + Slider, + TextField, + Typography, +} from "@mui/material"; +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { RenderStack, RenderStackEntry, RenderStackSpatialElementType } from "@spatialdata/layers"; +import type { LayerConfig } from "@spatialdata/vis"; +import { useSpatialData } from "@spatialdata/react"; +import { observer } from "mobx-react-lite"; +import { runInAction } from "mobx"; +import { useCallback, useMemo, useState } from "react"; + +import { + DECK_OVERLAY_IDS, + DECK_OVERLAY_LABELS, + deckHostLayerId, + deckIdFromHostLayerId, + type DeckOverlayId, +} from "@/react/spatialdata/host_overlay_ids"; +import { renderStackEntryDisplayName, renderStackOrderLabel } from "@/react/spatialdata/render_stack_display"; +import { + insertHostRenderStackEntry, + insertSpatialRenderStackEntry, + isRemovableRenderStackEntry, + patchRenderStackEntry, + removeRenderStackEntry, + reorderRenderStackEntries, + renderStackEntryIds, +} from "@/react/spatialdata/render_stack_mutations"; +import { + defaultPropsForSpatialElement, + listAvailableSpatialEntries, +} from "@/react/spatialdata/render_stack_seed"; +import { NO_TABLE_ASSOCIATION } from "@/react/spatialdata/table_association"; +import { useChart, useDataStore } from "../context"; +import { useConfig } from "../hooks"; +import type { SpatialDataMdvReact, SpatialDataMdvReactConfig } from "./SpatialDataMDVReact"; +import DeckOverlayLayerPanel from "./spatialLayers/DeckOverlayLayerPanel"; +import ImageLayerPanel from "./spatialLayers/ImageLayerPanel"; +import LabelsLayerPanel from "./spatialLayers/LabelsLayerPanel"; +import PointsLayerPanel from "./spatialLayers/PointsLayerPanel"; +import ShapesLayerPanel from "./spatialLayers/ShapesLayerPanel"; + +type InsertOption = + | { kind: "spatial"; type: RenderStackSpatialElementType; elementKey: string; label: string } + | { kind: "host"; deckId: DeckOverlayId; label: string }; + +function getAvailableFields(dataStore: ReturnType): string[] { + return Object.keys(dataStore.columnIndex).sort(); +} + +function spatialPropsAsLayerConfig( + entry: Extract, +): LayerConfig { + return { + id: entry.id, + type: entry.source.elementType, + elementKey: entry.source.elementKey, + visible: entry.visible, + opacity: typeof entry.props.opacity === "number" ? entry.props.opacity : 1, + ...entry.props, + } as LayerConfig; +} + +function LayerDetails({ + entry, + onPatchProps, +}: { + entry: RenderStackEntry; + onPatchProps: (entryId: string, props: Record, merge?: boolean) => void; +}) { + const dataStore = useDataStore(); + const availableFields = useMemo(() => getAvailableFields(dataStore), [dataStore]); + + if (entry.kind === "host") { + const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); + if (!deckId) return null; + return ; + } + + if (entry.kind !== "spatial") return null; + + const layer = spatialPropsAsLayerConfig(entry); + const patchLayer = (updates: Partial) => { + onPatchProps(entry.id, updates as Record); + }; + + switch (entry.source.elementType) { + case "image": + return ( + } + updateLayer={patchLayer} + patchLayer={patchLayer} + /> + ); + case "shapes": + return ( + } + association={NO_TABLE_ASSOCIATION} + availableFields={availableFields} + updateLayer={patchLayer} + /> + ); + case "points": + return ( + } + updateLayer={patchLayer} + /> + ); + case "labels": + return ( + } + association={NO_TABLE_ASSOCIATION} + availableFields={availableFields} + updateLayer={patchLayer} + /> + ); + default: + return null; + } +} + +const SortableLayerAccordion = observer(function SortableLayerAccordion({ + entry, + onToggleVisible, + onOpacityChange, + onRemove, + onPatchProps, +}: { + entry: RenderStackEntry; + onToggleVisible: (entryId: string, visible: boolean) => void; + onOpacityChange: (entryId: string, opacity: number) => void; + onRemove: (entryId: string) => void; + onPatchProps: (entryId: string, props: Record) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: entry.id }); + const [isHovered, setIsHovered] = useState(false); + + const opacity = + typeof entry.props.opacity === "number" ? entry.props.opacity : 1; + const supportsOpacity = entry.kind === "spatial"; + + const style = { + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + }> + {isRemovableRenderStackEntry(entry) && ( + { + event.stopPropagation(); + onRemove(entry.id); + }} + aria-label="remove layer" + size="small" + sx={{ + position: "absolute", + right: "-18px", + top: "-18px", + opacity: isHovered ? 1 : 0, + transition: "opacity 0.3s", + }} + > + + + )} +
+ + + + event.stopPropagation()} + onChange={(event) => onToggleVisible(entry.id, event.target.checked)} + /> + + {renderStackEntryDisplayName(entry)} + + {supportsOpacity && ( +
event.stopPropagation()} + > + { + if (typeof value === "number") { + onOpacityChange(entry.id, value); + } + }} + /> +
+ )} +
+
+ + + +
+ ); +}); + +const SpatialLayerDialogComponent = observer(() => { + const chart = useChart(); + const config = useConfig(); + const { spatialData } = useSpatialData(); + const stack = config.renderStack; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }), + ); + + const updateStack = useCallback( + (mutator: (current: RenderStack) => void) => { + runInAction(() => { + if (!config.renderStack) return; + mutator(config.renderStack); + }); + }, + [config], + ); + + const onPatchProps = useCallback( + (entryId: string, props: Record) => { + updateStack((current) => { + patchRenderStackEntry(current, entryId, { props }); + }); + }, + [updateStack], + ); + + const insertOptions = useMemo(() => { + if (!stack || !spatialData) return []; + const options: InsertOption[] = []; + const coordinateSystem = + (chart.dataStore.regions?.all_regions[config.region] as { spatial?: { coordinate_system?: string } }) + ?.spatial?.coordinate_system ?? null; + if (coordinateSystem) { + const available = listAvailableSpatialEntries(spatialData, coordinateSystem); + for (const entry of available) { + if (entry.kind !== "spatial") continue; + if (stack.entries.some((item) => item.id === entry.id)) continue; + options.push({ + kind: "spatial", + type: entry.source.elementType, + elementKey: entry.source.elementKey, + label: `${entry.source.elementType}: ${entry.source.elementKey}`, + }); + } + } + for (const deckId of DECK_OVERLAY_IDS) { + const id = deckHostLayerId(deckId); + if (stack.entries.some((entry) => entry.id === id)) continue; + options.push({ + kind: "host", + deckId, + label: DECK_OVERLAY_LABELS[deckId], + }); + } + return options.sort((a, b) => a.label.localeCompare(b.label)); + }, [chart.dataStore.regions?.all_regions, config.region, spatialData, stack]); + + const onDragEnd = useCallback( + (event: DragEndEvent) => { + if (!stack) return; + const { active, over } = event; + if (!over || active.id === over.id) return; + const ids = renderStackEntryIds(stack); + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + updateStack((current) => { + reorderRenderStackEntries(current, oldIndex, newIndex); + }); + }, + [stack, updateStack], + ); + + const onInsert = useCallback( + (option: InsertOption | null) => { + if (!option) return; + updateStack((current) => { + if (option.kind === "spatial") { + insertSpatialRenderStackEntry( + current, + option.type, + option.elementKey, + defaultPropsForSpatialElement(option.type), + ); + } else { + insertHostRenderStackEntry(current, option.deckId); + } + }); + }, + [updateStack], + ); + + if (!stack?.entries.length) { + return ( +
+ + Layer stack is loading. Open this dialog again after spatial data has loaded. + +
+ ); + } + + const entryIds = renderStackEntryIds(stack); + + return ( +
+ + + {stack.entries.map((entry) => ( + { + updateStack((current) => { + patchRenderStackEntry(current, entryId, { visible }); + }); + }} + onOpacityChange={(entryId, opacity) => { + updateStack((current) => { + patchRenderStackEntry(current, entryId, { + props: { opacity }, + }); + }); + }} + onRemove={(entryId) => { + updateStack((current) => { + removeRenderStackEntry(current, entryId); + }); + }} + onPatchProps={onPatchProps} + /> + ))} + + +
+ + option.kind === "spatial" ? "Spatial elements" : "Deck overlays" + } + getOptionLabel={(option) => option.label} + onChange={(_, value) => onInsert(value)} + renderInput={(params) => ( + + )} + /> +
+ + Layer order: {renderStackOrderLabel(stack)} + +
+ ); +}); + +export default SpatialLayerDialogComponent; diff --git a/src/react/components/SpatialLayerDialogReactWrapper.tsx b/src/react/components/SpatialLayerDialogReactWrapper.tsx new file mode 100644 index 000000000..546f6bbb0 --- /dev/null +++ b/src/react/components/SpatialLayerDialogReactWrapper.tsx @@ -0,0 +1,88 @@ +import { observer } from "mobx-react-lite"; +import { SpatialDataProvider } from "@spatialdata/react"; +import { BaseDialog } from "../../utilities/Dialog"; +import { createEl } from "../../utilities/ElementsTyped"; +import { createMdvPortal } from "@/react/react_utils"; +import { getProjectURL } from "@/dataloaders/DataLoaderUtil"; +import { ChartProvider } from "../context"; +import { useRegion } from "../hooks"; +import SpatialLayerDialogComponent from "./SpatialLayerDialogComponent"; +import type { SpatialDataMdvReact } from "./SpatialDataMDVReact"; + +function getSpatialDataUrl(region: unknown): string | undefined { + if (!region || typeof region !== "object" || !("spatial" in region)) return undefined; + const spatial = region.spatial; + if (!spatial || typeof spatial !== "object" || !("file" in spatial)) return undefined; + const file = spatial.file; + if (typeof file !== "string" || !file) return undefined; + return getProjectURL(`spatial/${file}`); +} + +const SpatialLayerDialogReact = observer(function SpatialLayerDialogReact() { + const rawRegion = useRegion(); + const spatialDataUrl = getSpatialDataUrl(rawRegion); + return ( + + + + ); +}); + +class SpatialLayerDialogReactWrapper extends BaseDialog { + _root?: ReturnType; + get root() { + return this._root; + } + set root(v) { + this._root = v; + } + + constructor(parent: SpatialDataMdvReact) { + if (parent.__doc__ !== document) { + console.warn( + "SpatialLayerDialogReactWrapper may have styling issues in popouts...", + ); + } + const config = { + width: 560, + maxHeight: 720, + title: `Layers (${parent.config.title})`, + doc: parent.__doc__ || document, + onclose: () => { + parent.layerDialog = undefined; + parent.dialogs.splice(parent.dialogs.indexOf(this), 1); + }, + }; + super(config, parent); + } + + init(parent: SpatialDataMdvReact) { + const div = createEl( + "div", + { + styles: { + display: "block", + width: "100%", + height: "100%", + }, + }, + this.dialog, + ); + this.root = createMdvPortal( + + + , + div, + this, + ); + } + + close() { + super.close(); + if (this.root) this.root.unmount(); + else console.warn("not unmounting react root for spatial layer dialog"); + } +} + +BaseDialog.experiment["SpatialLayerDialogReact"] = SpatialLayerDialogReactWrapper; +export default SpatialLayerDialogReactWrapper; diff --git a/src/react/components/spatialLayers/DeckOverlayLayerPanel.tsx b/src/react/components/spatialLayers/DeckOverlayLayerPanel.tsx new file mode 100644 index 000000000..d98f74a27 --- /dev/null +++ b/src/react/components/spatialLayers/DeckOverlayLayerPanel.tsx @@ -0,0 +1,12 @@ +import { Typography } from "@mui/material"; +import type { DeckOverlayId } from "@/react/spatialdata/host_overlay_ids"; +import { DECK_OVERLAY_LABELS } from "@/react/spatialdata/host_overlay_ids"; + +export default function DeckOverlayLayerPanel({ deckId }: { deckId: DeckOverlayId }) { + return ( + + {DECK_OVERLAY_LABELS[deckId]} visibility is controlled from the accordion header. + Opacity is not supported for this overlay type. + + ); +} diff --git a/src/react/components/spatialLayers/ImageLayerPanel.tsx b/src/react/components/spatialLayers/ImageLayerPanel.tsx new file mode 100644 index 000000000..06cc09640 --- /dev/null +++ b/src/react/components/spatialLayers/ImageLayerPanel.tsx @@ -0,0 +1,291 @@ +import { + Checkbox, + FormControl, + InputLabel, + MenuItem, + Select, + Slider, + Typography, +} from "@mui/material"; +import type { LayerConfig } from "@spatialdata/vis"; +import { useEffect, useId, useMemo, useState } from "react"; +import { useDebounce } from "use-debounce"; +import { PopoverPicker } from "../ColorPicker"; + +type ImageLayerConfig = Extract; +type ChannelConfig = NonNullable; +type Range = [number, number]; + +type LoaderDefaults = { + colors?: [number, number, number][]; + contrastLimits?: [number, number][]; + channelsVisible?: boolean[]; + selections?: ChannelConfig["selections"]; +}; + +type Props = { + layerId: string; + config: ImageLayerConfig; + imageSource?: "spatial" | "ome_tiff"; + loaderDefaults?: LoaderDefaults; + channelNames?: string[]; + updateLayer: (updates: Partial) => void; + patchLayer: (updates: Partial) => void; +}; + +function copyRange(value: [number, number] | undefined, fallback: Range): Range { + if (!value) return fallback; + return [value[0], value[1]]; +} + +function withChannelValue(values: T[] | undefined, index: number, value: T, fallback: T): T[] { + const next = [...(values ?? [])]; + while (next.length <= index) { + next.push(fallback); + } + next[index] = value; + return next; +} + +function clampContrast(value: Range, domain: Range): Range { + const [domainMin, domainMax] = domain; + let [low, high] = value; + low = Math.max(domainMin, Math.min(low, domainMax)); + high = Math.max(domainMin, Math.min(high, domainMax)); + if (low >= high) { + if (high < domainMax) { + low = Math.max(domainMin, high - 1); + } else { + high = Math.min(domainMax, low + 1); + } + } + return [low, high]; +} + +function getChannelContrast( + channels: ChannelConfig, + loaderDefaults: LoaderDefaults | undefined, + index: number, + domain: Range, +): Range { + const fromConfig = channels.contrastLimits?.[index]; + if (fromConfig) return copyRange(fromConfig, domain); + const fromLoader = loaderDefaults?.contrastLimits?.[index]; + if (fromLoader) return copyRange(fromLoader, domain); + return domain; +} + +export default function ImageLayerPanel({ + config, + imageSource = "spatial" as const, + loaderDefaults, + channelNames = [], + updateLayer, + patchLayer, +}: Props) { + const channels = config.channels ?? {}; + const colors = channels.colors ?? loaderDefaults?.colors ?? [[255, 0, 0]]; + const channelsVisible = + channels.channelsVisible ?? loaderDefaults?.channelsVisible ?? colors.map(() => true); + const selections = channels.selections ?? loaderDefaults?.selections ?? [{ c: 0 }]; + + const channelCount = Math.max( + colors.length, + loaderDefaults?.contrastLimits?.length ?? 0, + channels.contrastLimits?.length ?? 0, + channelsVisible.length, + 1, + ); + const channelIndexes = useMemo( + () => Array.from({ length: channelCount }, (_, index) => index), + [channelCount], + ); + + const buildChannels = (patch: Partial): ChannelConfig => ({ + colors, + contrastLimits: channels.contrastLimits, + channelsVisible, + selections, + ...channels, + ...patch, + }); + + const applyChannelPatch = (patch: Partial, persist = false) => { + const nextChannels = buildChannels(patch); + patchLayer({ channels: nextChannels }); + if (persist) { + updateLayer({ channels: nextChannels }); + } + }; + + return ( +
+ + Image source: {imageSource === "spatial" ? "SpatialData zarr" : "OME-TIFF"} + + {channelIndexes.map((index) => { + const domain = copyRange( + loaderDefaults?.contrastLimits?.[index], + [0, 255], + ); + return ( + { + applyChannelPatch({ + channelsVisible: withChannelValue(channelsVisible, index, visible, true), + }, true); + }} + onColorChange={(color) => { + applyChannelPatch({ + colors: withChannelValue(colors, index, color, [255, 0, 0]), + }, true); + }} + onContrastChange={(limits, persist) => { + applyChannelPatch({ + contrastLimits: withChannelValue( + channels.contrastLimits ?? loaderDefaults?.contrastLimits, + index, + limits, + domain, + ), + }, persist); + }} + onChannelSelect={(channelIndex) => { + const nextSelections = [...selections]; + while (nextSelections.length <= index) { + nextSelections.push({ c: 0 }); + } + nextSelections[index] = { ...nextSelections[index], c: channelIndex }; + applyChannelPatch({ selections: nextSelections }, true); + }} + /> + ); + })} +
+ ); +} + +function ChannelRow({ + index, + name, + color, + contrast, + domain, + visible, + channelOptions, + selectedChannel, + onVisibleChange, + onColorChange, + onContrastChange, + onChannelSelect, +}: { + index: number; + name: string; + color: [number, number, number]; + contrast: Range; + domain: Range; + visible: boolean; + channelOptions: string[]; + selectedChannel: number; + onVisibleChange: (visible: boolean) => void; + onColorChange: (color: [number, number, number]) => void; + onContrastChange: (limits: Range, persist: boolean) => void; + onChannelSelect: (channelIndex: number) => void; +}) { + const labelId = useId(); + const [domainMin, domainMax] = domain; + const [liveContrast, setLiveContrast] = useState(null); + const [debouncedContrast] = useDebounce(liveContrast, 50); + + useEffect(() => { + setLiveContrast(null); + }, [contrast[0], contrast[1]]); + + useEffect(() => { + if (!debouncedContrast) return; + if (debouncedContrast[0] === contrast[0] && debouncedContrast[1] === contrast[1]) { + return; + } + onContrastChange(debouncedContrast, true); + }, [contrast, debouncedContrast, onContrastChange]); + + const [low, high] = liveContrast ?? contrast; + const sliderStep = Math.max(1, Math.round((domainMax - domainMin) / 500)); + + return ( +
+
+ + {name} + + onVisibleChange(event.target.checked)} + /> +
+ {channelOptions.length > 0 && ( + + Channel + + + )} +
+ +
+
+
+ Low + { + if (typeof value !== "number") return; + const next = clampContrast([value, high], domain); + setLiveContrast(next); + onContrastChange(next, false); + }} + /> +
+
+ High + { + if (typeof value !== "number") return; + const next = clampContrast([low, value], domain); + setLiveContrast(next); + onContrastChange(next, false); + }} + /> +
+
+
+ ); +} diff --git a/src/react/components/spatialLayers/LabelsLayerPanel.tsx b/src/react/components/spatialLayers/LabelsLayerPanel.tsx new file mode 100644 index 000000000..ce337d8e5 --- /dev/null +++ b/src/react/components/spatialLayers/LabelsLayerPanel.tsx @@ -0,0 +1,63 @@ +import { + Autocomplete, + Chip, + TextField, + Typography, +} from "@mui/material"; +import type { LayerConfig } from "@spatialdata/vis"; + +type LabelsLayerConfig = Extract; +import { useMemo } from "react"; +import type { TableAssociation } from "@/react/spatialdata/table_association"; + +type Props = { + config: LabelsLayerConfig; + updateLayer: (updates: Partial) => void; + association: TableAssociation; + availableFields: string[]; +}; + +export default function LabelsLayerPanel({ + config, + updateLayer, + association, + availableFields, +}: Props) { + const selected = config.tooltipFields ?? []; + const options = useMemo(() => { + const set = new Set(availableFields); + for (const field of selected) set.add(field); + return [...set].sort(); + }, [availableFields, selected]); + + return ( +
+ {association.status === "ambiguous" && ( + + Multiple tables match this element. Tooltip fields may be incomplete. + + )} + {association.status === "none" && ( + + No associated table inferred. Add tooltip fields manually. + + )} + updateLayer({ tooltipFields: value })} + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> +
+ ); +} diff --git a/src/react/components/spatialLayers/PointsLayerPanel.tsx b/src/react/components/spatialLayers/PointsLayerPanel.tsx new file mode 100644 index 000000000..dfb7e82b6 --- /dev/null +++ b/src/react/components/spatialLayers/PointsLayerPanel.tsx @@ -0,0 +1,52 @@ +import { Slider, TextField } from "@mui/material"; +import type { LayerConfig } from "@spatialdata/vis"; + +type PointsLayerConfig = Extract; + +type Props = { + config: PointsLayerConfig; + updateLayer: (updates: Partial) => void; +}; + +export default function PointsLayerPanel({ config, updateLayer }: Props) { + const pointSize = config.pointSize ?? 4; + const color = config.color ?? [100, 149, 237, 200]; + + return ( +
+
+ + Size + + { + if (typeof value === "number") { + updateLayer({ pointSize: value }); + } + }} + /> +
+
+ {(["R", "G", "B", "A"] as const).map((label, index) => ( + { + const next = [...color] as [number, number, number, number]; + next[index] = Number(event.target.value); + updateLayer({ color: next }); + }} + /> + ))} +
+
+ ); +} diff --git a/src/react/components/spatialLayers/ShapesLayerPanel.tsx b/src/react/components/spatialLayers/ShapesLayerPanel.tsx new file mode 100644 index 000000000..18203f352 --- /dev/null +++ b/src/react/components/spatialLayers/ShapesLayerPanel.tsx @@ -0,0 +1,163 @@ +import { + Autocomplete, + Chip, + MenuItem, + Select, + Slider, + TextField, + Typography, +} from "@mui/material"; +import type { LayerConfig } from "@spatialdata/vis"; + +type ShapesLayerConfig = Extract; +import { useMemo } from "react"; +import type { TableAssociation } from "@/react/spatialdata/table_association"; +import { useConfig } from "@/react/hooks"; +import type { VivMdvReactConfig } from "../VivMDVReact"; + +type Props = { + config: ShapesLayerConfig; + updateLayer: (updates: Partial) => void; + association: TableAssociation; + availableFields: string[]; +}; + +function ColorFields({ + label, + value, + onChange, +}: { + label: string; + value: [number, number, number, number]; + onChange: (next: [number, number, number, number]) => void; +}) { + return ( +
+ {label} +
+ {(["R", "G", "B", "A"] as const).map((channel, index) => ( + { + const next = [...value] as [number, number, number, number]; + next[index] = Number(event.target.value); + onChange(next); + }} + /> + ))} +
+
+ ); +} + +export default function ShapesLayerPanel({ + config, + updateLayer, + association, + availableFields, +}: Props) { + const chartConfig = useConfig(); + const fillColor = config.fillColor ?? [200, 200, 200, 120]; + const strokeColor = config.strokeColor ?? [255, 255, 255, 200]; + const tooltipFields = config.tooltipFields ?? []; + const fillByColumn = config.fillColorByColumn?.columnName ?? ""; + + const options = useMemo(() => { + const set = new Set(availableFields); + if (chartConfig.color_by && typeof chartConfig.color_by === "string") { + set.add(chartConfig.color_by); + } + for (const field of tooltipFields) set.add(field); + if (fillByColumn) set.add(fillByColumn); + return [...set].sort(); + }, [availableFields, chartConfig.color_by, fillByColumn, tooltipFields]); + + return ( +
+ {association.status === "resolved" && association.tableName && ( + + Associated table: {association.tableName} + + )} + {association.status === "ambiguous" && ( + + Table association is ambiguous. Choose columns manually. + + )} +
+ + Stroke width + + { + if (typeof value === "number") { + updateLayer({ strokeWidth: value }); + } + }} + /> +
+ updateLayer({ fillColor: next })} + /> + updateLayer({ strokeColor: next })} + /> + updateLayer({ tooltipFields: value })} + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + +
+ ); +} diff --git a/src/react/spatialdata/host_layer_resolver.ts b/src/react/spatialdata/host_layer_resolver.ts new file mode 100644 index 000000000..d36f04590 --- /dev/null +++ b/src/react/spatialdata/host_layer_resolver.ts @@ -0,0 +1,14 @@ +import type { RenderStackHostEntry } from "@spatialdata/layers"; +import type { Layer } from "@deck.gl/core"; + +import { deckIdFromHostLayerId, type DeckOverlayId } from "./host_overlay_ids"; + +export type MdvDeckOverlayLayers = Record; + +export function createMdvHostLayerResolver(overlays: MdvDeckOverlayLayers) { + return (entry: RenderStackHostEntry): Layer | null => { + const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); + if (!deckId) return null; + return overlays[deckId] ?? null; + }; +} diff --git a/src/react/spatialdata/host_overlay_ids.ts b/src/react/spatialdata/host_overlay_ids.ts new file mode 100644 index 000000000..b5de862a2 --- /dev/null +++ b/src/react/spatialdata/host_overlay_ids.ts @@ -0,0 +1,36 @@ +export type DeckOverlayId = + | "grey_scatter" + | "scatter" + | "gate_display" + | "selection" + | "gate_labels"; + +export const DECK_OVERLAY_IDS: DeckOverlayId[] = [ + "grey_scatter", + "scatter", + "gate_display", + "selection", + "gate_labels", +]; + +export const DECK_OVERLAY_LABELS: Record = { + grey_scatter: "Background scatter", + scatter: "Scatter", + gate_display: "Gates", + selection: "Selection", + gate_labels: "Gate labels", +}; + +export function deckHostLayerId(deckId: DeckOverlayId): string { + return `deck:${deckId}`; +} + +export function isDeckHostLayerId(id: string): boolean { + return id.startsWith("deck:"); +} + +export function deckIdFromHostLayerId(hostLayerId: string): DeckOverlayId | null { + if (!hostLayerId.startsWith("deck:")) return null; + const deckId = hostLayerId.slice("deck:".length) as DeckOverlayId; + return DECK_OVERLAY_IDS.includes(deckId) ? deckId : null; +} diff --git a/src/react/spatialdata/render_stack_display.ts b/src/react/spatialdata/render_stack_display.ts new file mode 100644 index 000000000..c7d20e901 --- /dev/null +++ b/src/react/spatialdata/render_stack_display.ts @@ -0,0 +1,21 @@ +import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; + +import { DECK_OVERLAY_LABELS, deckIdFromHostLayerId } from "./host_overlay_ids"; + +export function renderStackEntryDisplayName(entry: RenderStackEntry): string { + if (entry.kind === "host") { + const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); + if (deckId) { + return DECK_OVERLAY_LABELS[deckId]; + } + return entry.source.hostLayerId; + } + if (entry.kind === "spatial") { + return `${entry.source.elementType}: ${entry.source.elementKey}`; + } + return entry.id; +} + +export function renderStackOrderLabel(stack: RenderStack): string { + return stack.entries.map((entry) => renderStackEntryDisplayName(entry)).join(" → "); +} diff --git a/src/react/spatialdata/render_stack_mutations.ts b/src/react/spatialdata/render_stack_mutations.ts new file mode 100644 index 000000000..1a63d732c --- /dev/null +++ b/src/react/spatialdata/render_stack_mutations.ts @@ -0,0 +1,87 @@ +import type { RenderStack, RenderStackEntry, RenderStackSpatialElementType } from "@spatialdata/layers"; + +import { deckHostLayerId, deckIdFromHostLayerId, type DeckOverlayId } from "./host_overlay_ids"; +import { spatialEntryId } from "./render_stack_seed"; + +export function reorderRenderStackEntries( + stack: RenderStack, + fromIndex: number, + toIndex: number, +): void { + const next = [...stack.entries]; + const [moved] = next.splice(fromIndex, 1); + if (!moved) return; + next.splice(toIndex, 0, moved); + stack.entries = next; +} + +export function patchRenderStackEntry( + stack: RenderStack, + entryId: string, + patch: { + visible?: boolean; + props?: Record; + }, +): void { + const entry = stack.entries.find((item) => item.id === entryId); + if (!entry) return; + if (patch.visible !== undefined) { + entry.visible = patch.visible; + } + if (patch.props) { + entry.props = { ...entry.props, ...patch.props }; + } +} + +export function insertSpatialRenderStackEntry( + stack: RenderStack, + elementType: RenderStackSpatialElementType, + elementKey: string, + props: Record = {}, +): boolean { + const id = spatialEntryId(elementType, elementKey); + if (stack.entries.some((entry) => entry.id === id)) { + return false; + } + stack.entries.push({ + kind: "spatial", + id, + visible: true, + source: { elementType, elementKey }, + props: { opacity: 1, ...props }, + }); + return true; +} + +export function insertHostRenderStackEntry( + stack: RenderStack, + deckId: DeckOverlayId, +): boolean { + const id = deckHostLayerId(deckId); + if (stack.entries.some((entry) => entry.id === id)) { + return false; + } + stack.entries.push({ + kind: "host", + id, + visible: true, + source: { hostLayerId: id }, + props: {}, + }); + return true; +} + +export function removeRenderStackEntry(stack: RenderStack, entryId: string): void { + stack.entries = stack.entries.filter((entry) => entry.id !== entryId); +} + +export function renderStackEntryIds(stack: RenderStack): string[] { + return stack.entries.map((entry) => entry.id); +} + +export function isRemovableRenderStackEntry(entry: RenderStackEntry): boolean { + if (entry.kind === "host") { + return deckIdFromHostLayerId(entry.source.hostLayerId) !== null; + } + return entry.kind === "spatial"; +} diff --git a/src/react/spatialdata/render_stack_seed.ts b/src/react/spatialdata/render_stack_seed.ts new file mode 100644 index 000000000..1ffacbd01 --- /dev/null +++ b/src/react/spatialdata/render_stack_seed.ts @@ -0,0 +1,156 @@ +import type { SpatialData, SpatialElement } from "@spatialdata/core"; +import { + RENDER_STACK_SCHEMA_VERSION, + type RenderStack, + type RenderStackEntry, + type RenderStackSpatialElementType, +} from "@spatialdata/layers"; + +import { + DECK_OVERLAY_IDS, + deckHostLayerId, +} from "./host_overlay_ids"; + +function elementsForCoordinateSystem( + elements: Record | undefined, + coordinateSystem: string, +): Array<[string, T]> { + return Object.entries(elements ?? {}).filter(([, element]) => + element.coordinateSystems.includes(coordinateSystem), + ); +} + +export function spatialEntryId( + elementType: RenderStackSpatialElementType, + elementKey: string, +) { + return `spatialdata-${elementType}-${elementKey}`; +} + +function spatialEntry( + elementType: RenderStackSpatialElementType, + elementKey: string, + props: Record = {}, +): RenderStackEntry { + return { + kind: "spatial", + id: spatialEntryId(elementType, elementKey), + visible: true, + source: { elementType, elementKey }, + props: { opacity: 1, ...props }, + }; +} + +function hostEntry(deckId: (typeof DECK_OVERLAY_IDS)[number]): RenderStackEntry { + const hostLayerId = deckHostLayerId(deckId); + return { + kind: "host", + id: hostLayerId, + visible: true, + source: { hostLayerId }, + props: {}, + }; +} + +export function defaultPropsForSpatialElement( + elementType: RenderStackSpatialElementType, +): Record { + switch (elementType) { + case "shapes": + return { + fillColor: [100, 149, 237, 180], + strokeWidth: 1, + strokeWidthUnits: "pixels", + }; + case "points": + return { pointSize: 4 }; + default: + return {}; + } +} + +function addAvailableSpatialEntries( + spatialEntries: RenderStackEntry[], + elementType: RenderStackSpatialElementType, + elements: Record | undefined, + coordinateSystem: string, +) { + for (const [key] of elementsForCoordinateSystem(elements, coordinateSystem)) { + spatialEntries.push( + spatialEntry(elementType, key, defaultPropsForSpatialElement(elementType)), + ); + } +} + +/** All spatial elements in the store — for dialog "insert layer" options only. */ +export function listAvailableSpatialEntries( + spatialData: SpatialData, + coordinateSystem: string, +): RenderStackEntry[] { + const spatialEntries: RenderStackEntry[] = []; + addAvailableSpatialEntries(spatialEntries, "image", spatialData.images, coordinateSystem); + addAvailableSpatialEntries(spatialEntries, "shapes", spatialData.shapes, coordinateSystem); + addAvailableSpatialEntries(spatialEntries, "points", spatialData.points, coordinateSystem); + addAvailableSpatialEntries(spatialEntries, "labels", spatialData.labels, coordinateSystem); + + return spatialEntries; +} + +function firstImageSpatialEntry( + spatialData: SpatialData, + coordinateSystem: string, +): RenderStackEntry | null { + const matching = elementsForCoordinateSystem(spatialData.images, coordinateSystem); + const firstKey = matching[0]?.[0]; + if (!firstKey) return null; + return spatialEntry("image", firstKey); +} + +export function createDefaultRenderStack( + spatialData: SpatialData, + coordinateSystem: string, +): RenderStack { + const spatialEntries: RenderStackEntry[] = []; + const imageEntry = firstImageSpatialEntry(spatialData, coordinateSystem); + if (imageEntry) { + spatialEntries.push(imageEntry); + } + const hostEntries = DECK_OVERLAY_IDS.map((deckId) => hostEntry(deckId)); + return { + schemaVersion: RENDER_STACK_SCHEMA_VERSION, + entries: [...spatialEntries, ...hostEntries], + }; +} + +export function normalizeRenderStack( + stack: RenderStack | undefined, + spatialData: SpatialData, + coordinateSystem: string, +): RenderStack { + const defaults = createDefaultRenderStack(spatialData, coordinateSystem); + if (!stack?.entries?.length) { + return defaults; + } + + const entriesById = new Map(); + for (const entry of defaults.entries) { + entriesById.set(entry.id, entry); + } + for (const entry of stack.entries) { + entriesById.set(entry.id, entry); + } + + const order = stack.entries.map((entry) => entry.id); + for (const entry of defaults.entries) { + if (!order.includes(entry.id)) { + order.push(entry.id); + } + } + + return { + schemaVersion: RENDER_STACK_SCHEMA_VERSION, + entries: order + .map((id) => entriesById.get(id)) + .filter((entry): entry is RenderStackEntry => entry !== undefined), + }; +} diff --git a/src/react/spatialdata/smoke_import.ts b/src/react/spatialdata/smoke_import.ts new file mode 100644 index 000000000..255d9b39e --- /dev/null +++ b/src/react/spatialdata/smoke_import.ts @@ -0,0 +1,6 @@ +/** Compile-time smoke check that @spatialdata/* public API resolves. */ +import type { RenderStack } from "@spatialdata/layers"; +import type { SpatialCanvasViewer } from "@spatialdata/vis"; + +export type SpatialDataSmokeRenderStack = RenderStack; +export type SpatialDataSmokeViewer = typeof SpatialCanvasViewer; diff --git a/src/react/spatialdata/spatial_feature_tooltip.ts b/src/react/spatialdata/spatial_feature_tooltip.ts new file mode 100644 index 000000000..0ccd71bf4 --- /dev/null +++ b/src/react/spatialdata/spatial_feature_tooltip.ts @@ -0,0 +1,33 @@ +import type { SpatialFeatureTooltipData } from "@spatialdata/vis"; + +export function formatSpatialFeatureTooltipHtml(tooltip: SpatialFeatureTooltipData): string { + const lines: string[] = []; + if (tooltip.title) { + lines.push(`${escapeHtml(tooltip.title)}`); + } + const sections = tooltip.sections ?? []; + for (const section of sections) { + if (section.title) { + lines.push(`
${escapeHtml(section.title)}
`); + } + for (const item of section.items ?? []) { + lines.push( + `
${escapeHtml(item.label)}: ${escapeHtml(String(item.value ?? ""))}
`, + ); + } + } + for (const item of tooltip.items ?? []) { + lines.push( + `
${escapeHtml(item.label)}: ${escapeHtml(String(item.value ?? ""))}
`, + ); + } + return lines.join(""); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/src/react/spatialdata/table_association.ts b/src/react/spatialdata/table_association.ts new file mode 100644 index 000000000..d11fd8c38 --- /dev/null +++ b/src/react/spatialdata/table_association.ts @@ -0,0 +1,6 @@ +export type TableAssociation = + | { status: "none" } + | { status: "ambiguous" } + | { status: "resolved"; tableName?: string }; + +export const NO_TABLE_ASSOCIATION: TableAssociation = { status: "none" }; diff --git a/src/react/spatialdata/view_state_bridge.ts b/src/react/spatialdata/view_state_bridge.ts new file mode 100644 index 000000000..3cc77a2f9 --- /dev/null +++ b/src/react/spatialdata/view_state_bridge.ts @@ -0,0 +1,30 @@ +import type { ViewState as SpatialCanvasViewState } from "@spatialdata/vis"; +import type { OrbitViewState, OrthographicViewState } from "deck.gl"; + +export function toSpatialViewState( + viewState: OrthographicViewState | OrbitViewState | null | undefined, +): SpatialCanvasViewState | null { + if (!viewState || !Array.isArray(viewState.target)) return null; + const [x, y] = viewState.target; + if (typeof x !== "number" || typeof y !== "number") return null; + const zoom = typeof viewState.zoom === "number" ? viewState.zoom : 0; + return { + target: [x, y], + zoom, + }; +} + +export function toMdvViewState( + viewState: SpatialCanvasViewState, + previousViewState: OrthographicViewState | OrbitViewState | null | undefined, +): OrthographicViewState { + const [x, y] = viewState.target; + const previousTarget = Array.isArray(previousViewState?.target) + ? previousViewState.target + : [0, 0, 0]; + return { + ...(previousViewState ?? {}), + target: [x, y, previousTarget[2] ?? 0], + zoom: viewState.zoom, + }; +} From 5546f781e6254886d17924a4ef63dedc3da325ec Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 19 Jun 2026 13:25:29 +0100 Subject: [PATCH 03/47] fix mobx state for ShapesLayerPanel --- .../SpatialLayerDialogComponent.tsx | 20 +++++++++++++++++-- .../spatialLayers/ShapesLayerPanel.tsx | 14 ++++++------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/react/components/SpatialLayerDialogComponent.tsx b/src/react/components/SpatialLayerDialogComponent.tsx index 6ad2d07d4..194c17874 100644 --- a/src/react/components/SpatialLayerDialogComponent.tsx +++ b/src/react/components/SpatialLayerDialogComponent.tsx @@ -85,7 +85,15 @@ function spatialPropsAsLayerConfig( } as LayerConfig; } -function LayerDetails({ +/** + * MobX observer boundary for per-layer panels. + * + * Stack entries are mutated in place on `config.renderStack`. Panels stay as plain + * controlled components, but this wrapper must re-render when `entry.props` changes. + * Wrapping an individual panel in `observer` is not enough — it only receives a + * plain `config` snapshot from props, not the live observable entry. + */ +const LayerDetails = observer(function LayerDetails({ entry, onPatchProps, }: { @@ -93,7 +101,14 @@ function LayerDetails({ onPatchProps: (entryId: string, props: Record, merge?: boolean) => void; }) { const dataStore = useDataStore(); + const chartConfig = useConfig(); const availableFields = useMemo(() => getAvailableFields(dataStore), [dataStore]); + const chartColorBy = + typeof chartConfig.color_by === "string" ? chartConfig.color_by : undefined; + + // Subscribe to in-place prop edits (visibility/opacity are read by the accordion). + void entry.visible; + void entry.props; if (entry.kind === "host") { const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); @@ -124,6 +139,7 @@ function LayerDetails({ config={layer as Extract} association={NO_TABLE_ASSOCIATION} availableFields={availableFields} + chartColorBy={chartColorBy} updateLayer={patchLayer} /> ); @@ -146,7 +162,7 @@ function LayerDetails({ default: return null; } -} +}); const SortableLayerAccordion = observer(function SortableLayerAccordion({ entry, diff --git a/src/react/components/spatialLayers/ShapesLayerPanel.tsx b/src/react/components/spatialLayers/ShapesLayerPanel.tsx index 18203f352..c74b2ec37 100644 --- a/src/react/components/spatialLayers/ShapesLayerPanel.tsx +++ b/src/react/components/spatialLayers/ShapesLayerPanel.tsx @@ -12,14 +12,16 @@ import type { LayerConfig } from "@spatialdata/vis"; type ShapesLayerConfig = Extract; import { useMemo } from "react"; import type { TableAssociation } from "@/react/spatialdata/table_association"; -import { useConfig } from "@/react/hooks"; -import type { VivMdvReactConfig } from "../VivMDVReact"; type Props = { config: ShapesLayerConfig; updateLayer: (updates: Partial) => void; association: TableAssociation; availableFields: string[]; + /** Chart-level color_by column, for fill-by-column option list. + * nb this needs review, not working and also general background issues with colorBy type. + */ + chartColorBy?: string; }; function ColorFields({ @@ -59,8 +61,8 @@ export default function ShapesLayerPanel({ updateLayer, association, availableFields, + chartColorBy, }: Props) { - const chartConfig = useConfig(); const fillColor = config.fillColor ?? [200, 200, 200, 120]; const strokeColor = config.strokeColor ?? [255, 255, 255, 200]; const tooltipFields = config.tooltipFields ?? []; @@ -68,13 +70,11 @@ export default function ShapesLayerPanel({ const options = useMemo(() => { const set = new Set(availableFields); - if (chartConfig.color_by && typeof chartConfig.color_by === "string") { - set.add(chartConfig.color_by); - } + if (chartColorBy) set.add(chartColorBy); for (const field of tooltipFields) set.add(field); if (fillByColumn) set.add(fillByColumn); return [...set].sort(); - }, [availableFields, chartConfig.color_by, fillByColumn, tooltipFields]); + }, [availableFields, chartColorBy, fillByColumn, tooltipFields]); return (
From 026a7f066138567265e9e714bfe067c86fa5aa1a Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 19 Jun 2026 15:53:05 +0100 Subject: [PATCH 04/47] Avoid new object churn in spatial data rendering with new render stack management. Lots of boilerplate here that I hope we can reduce. - Introduced `renderStackGeneration` to track changes in render stack entries. - Added `bumpRenderStackGeneration` method to increment the generation counter. - Refactored `SpatialCanvasFromRenderStack` to utilize the new render stack management. - Implemented caching and synchronization for render stack layer inputs to optimize performance. - Updated layer dialog components to use new hooks for render stack entry management. - Improved handling of host layer resolution and cloning for better rendering efficiency. --- src/react/components/SpatialDataMDVReact.tsx | 8 + .../SpatialDataMDVReactComponent.tsx | 121 ++++++++++------ .../SpatialLayerDialogComponent.tsx | 137 +++++------------- src/react/spatialdata/host_layer_resolver.ts | 51 ++++++- .../spatialdata/render_stack_entry_state.ts | 100 +++++++++++++ .../spatialdata/render_stack_layer_inputs.ts | 53 +++++++ .../spatialdata/render_stack_mutations.ts | 12 +- 7 files changed, 331 insertions(+), 151 deletions(-) create mode 100644 src/react/spatialdata/render_stack_entry_state.ts create mode 100644 src/react/spatialdata/render_stack_layer_inputs.ts diff --git a/src/react/components/SpatialDataMDVReact.tsx b/src/react/components/SpatialDataMDVReact.tsx index a3382be24..79b9ad32a 100644 --- a/src/react/components/SpatialDataMDVReact.tsx +++ b/src/react/components/SpatialDataMDVReact.tsx @@ -46,11 +46,17 @@ class SpatialDataMdvReact extends BaseReactChart { vivStores: VivContextType; layerDialog?: SpatialLayerDialogReactWrapper; ignoreStateUpdate = false; + /** Bumped when render-stack entries/props change; canvas observer reads this. */ + renderStackGeneration = 0; get viewerStore() { return this.vivStores?.viewerStore; } + bumpRenderStackGeneration() { + this.renderStackGeneration++; + } + constructor( dataStore: DataStore, div: HTMLDivElement, @@ -63,6 +69,8 @@ class SpatialDataMdvReact extends BaseReactChart { colorBy: observable, colorByColumn: action, colorByDefault: action, + renderStackGeneration: observable, + bumpRenderStackGeneration: action, }); this.vivStores = createVivStores(); this.addMenuIcon("fas fa-layer-group", "Manage Layers").addEventListener( diff --git a/src/react/components/SpatialDataMDVReactComponent.tsx b/src/react/components/SpatialDataMDVReactComponent.tsx index 1b9887fd8..e97be3133 100644 --- a/src/react/components/SpatialDataMDVReactComponent.tsx +++ b/src/react/components/SpatialDataMDVReactComponent.tsx @@ -1,4 +1,4 @@ -import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; +import type { RenderStack } from "@spatialdata/layers"; import { SpatialDataProvider, useSpatialData } from "@spatialdata/react"; import { SpatialCanvasViewer, @@ -16,12 +16,18 @@ import { getCombinedScatterTooltip } from "@/lib/scatterTooltip"; import type { FieldName } from "@/charts/charts"; import { createMdvHostLayerResolver, + renderStackHostFingerprint, + resolveCachedHostDeckLayers, type MdvDeckOverlayLayers, } from "@/react/spatialdata/host_layer_resolver"; import { createDefaultRenderStack, normalizeRenderStack, } from "@/react/spatialdata/render_stack_seed"; +import { + createRenderStackLayerInputsCache, + syncRenderStackLayerInputs, +} from "@/react/spatialdata/render_stack_layer_inputs"; import { toMdvViewState, toSpatialViewState } from "@/react/spatialdata/view_state_bridge"; import { formatSpatialFeatureTooltipHtml } from "@/react/spatialdata/spatial_feature_tooltip"; import { useOuterContainer } from "../screen_state"; @@ -60,35 +66,72 @@ function getSpatialDataUrl(region: SpatialRegionMetadata) { return getProjectURL(`spatial/${file}`); } -function cloneRenderStackEntry(entry: RenderStackEntry): RenderStackEntry { - if (entry.kind === "group") { - return { - ...entry, - props: { ...entry.props }, - children: [...entry.children], - }; - } - if (entry.kind === "host") { - return { - ...entry, - props: { ...entry.props }, - source: { ...entry.source }, - }; +function observeSpatialRenderStack(stack: RenderStack | undefined) { + for (const entry of stack?.entries ?? []) { + if (entry.kind !== "spatial") continue; + void entry.id; + void entry.visible; + void entry.props; } - return { - ...entry, - props: { ...entry.props }, - source: { ...entry.source }, - }; } -function toPlainRenderStack(stack: RenderStack | undefined): RenderStack | undefined { - if (!stack) return undefined; - return { - schemaVersion: stack.schemaVersion, - entries: stack.entries.map(cloneRenderStackEntry), - }; -} +const SpatialCanvasFromRenderStack = observer(function SpatialCanvasFromRenderStack({ + spatialData, + coordinateSystem, + spatialViewState, + onSpatialViewStateChange, + hostLayerResolver, + deckProps, + onFeatureHover, +}: { + spatialData: NonNullable["spatialData"]>; + coordinateSystem: string; + spatialViewState: ReturnType; + onSpatialViewStateChange: (next: SpatialCanvasViewState) => void; + hostLayerResolver: ReturnType; + deckProps: Partial; + onFeatureHover: (event: SpatialFeaturePickEvent) => void; +}) { + const layerInputsCacheRef = useRef(createRenderStackLayerInputsCache()); + const config = useConfig(); + const chart = useChart(); + const stack = config.renderStack; + const generation = chart.renderStackGeneration; + void generation; + observeSpatialRenderStack(stack); + + const layerInputs = useMemo(() => { + if (!stack) return { layers: {}, layerOrder: [] as string[] }; + return syncRenderStackLayerInputs(stack, layerInputsCacheRef.current); + }, [stack, generation]); + + const hostFingerprint = renderStackHostFingerprint(stack); + const hostDeckLayers = useMemo( + () => resolveCachedHostDeckLayers(stack, hostLayerResolver), + [hostFingerprint, hostLayerResolver], + ); + + if (!stack?.entries.length) { + return
Preparing layer stack…
; + } + + return ( + + ); +}); function SpatialDataChartRoot() { const chart = useChart(); @@ -203,20 +246,10 @@ const SpatialDataViewer = observer( ? createDefaultRenderStack(spatialData, coordinateSystem) : normalizeRenderStack(config.renderStack, spatialData, coordinateSystem); config.renderStack = next; + chart.bumpRenderStackGeneration(); }); }, [config, coordinateSystem, loading, region?.spatial?.file, spatialData]); - // Dialog mutations patch `config.renderStack` in place (same object reference). - // Read nested entry fields during render so MobX re-runs this observer, then pass - // a fresh plain copy into SpatialCanvasViewer on every tracked change. - const renderStackEntries = config.renderStack?.entries; - renderStackEntries?.forEach((entry) => { - void entry.id; - void entry.visible; - void entry.props; - }); - const renderStack = toPlainRenderStack(config.renderStack); - const deckOverlaySources = useMemo( () => ({ grey_scatter: greyScatterplotLayer, @@ -348,7 +381,7 @@ const SpatialDataViewer = observer( return
Loading SpatialData store…
; } - if (!renderStack?.entries.length) { + if (!config.renderStack?.entries.length) { return
Preparing layer stack…
; } @@ -378,18 +411,14 @@ const SpatialDataViewer = observer( }} >
-
diff --git a/src/react/components/SpatialLayerDialogComponent.tsx b/src/react/components/SpatialLayerDialogComponent.tsx index 194c17874..440552236 100644 --- a/src/react/components/SpatialLayerDialogComponent.tsx +++ b/src/react/components/SpatialLayerDialogComponent.tsx @@ -26,12 +26,11 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { RenderStack, RenderStackEntry, RenderStackSpatialElementType } from "@spatialdata/layers"; +import type { RenderStackSpatialElementType } from "@spatialdata/layers"; import type { LayerConfig } from "@spatialdata/vis"; import { useSpatialData } from "@spatialdata/react"; import { observer } from "mobx-react-lite"; -import { runInAction } from "mobx"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { DECK_OVERLAY_IDS, @@ -41,11 +40,14 @@ import { type DeckOverlayId, } from "@/react/spatialdata/host_overlay_ids"; import { renderStackEntryDisplayName, renderStackOrderLabel } from "@/react/spatialdata/render_stack_display"; +import { + useRenderStackEntry, + useRenderStackMutation, +} from "@/react/spatialdata/render_stack_entry_state"; import { insertHostRenderStackEntry, insertSpatialRenderStackEntry, isRemovableRenderStackEntry, - patchRenderStackEntry, removeRenderStackEntry, reorderRenderStackEntries, renderStackEntryIds, @@ -72,43 +74,15 @@ function getAvailableFields(dataStore: ReturnType): string[ return Object.keys(dataStore.columnIndex).sort(); } -function spatialPropsAsLayerConfig( - entry: Extract, -): LayerConfig { - return { - id: entry.id, - type: entry.source.elementType, - elementKey: entry.source.elementKey, - visible: entry.visible, - opacity: typeof entry.props.opacity === "number" ? entry.props.opacity : 1, - ...entry.props, - } as LayerConfig; -} - -/** - * MobX observer boundary for per-layer panels. - * - * Stack entries are mutated in place on `config.renderStack`. Panels stay as plain - * controlled components, but this wrapper must re-render when `entry.props` changes. - * Wrapping an individual panel in `observer` is not enough — it only receives a - * plain `config` snapshot from props, not the live observable entry. - */ -const LayerDetails = observer(function LayerDetails({ - entry, - onPatchProps, -}: { - entry: RenderStackEntry; - onPatchProps: (entryId: string, props: Record, merge?: boolean) => void; -}) { +const LayerDetails = observer(function LayerDetails({ entryId }: { entryId: string }) { const dataStore = useDataStore(); const chartConfig = useConfig(); + const { entry, layer, patchLayer } = useRenderStackEntry(entryId); const availableFields = useMemo(() => getAvailableFields(dataStore), [dataStore]); const chartColorBy = typeof chartConfig.color_by === "string" ? chartConfig.color_by : undefined; - // Subscribe to in-place prop edits (visibility/opacity are read by the accordion). - void entry.visible; - void entry.props; + if (!entry) return null; if (entry.kind === "host") { const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); @@ -116,12 +90,7 @@ const LayerDetails = observer(function LayerDetails({ return ; } - if (entry.kind !== "spatial") return null; - - const layer = spatialPropsAsLayerConfig(entry); - const patchLayer = (updates: Partial) => { - onPatchProps(entry.id, updates as Record); - }; + if (entry.kind !== "spatial" || !layer) return null; switch (entry.source.elementType) { case "image": @@ -165,18 +134,13 @@ const LayerDetails = observer(function LayerDetails({ }); const SortableLayerAccordion = observer(function SortableLayerAccordion({ - entry, - onToggleVisible, - onOpacityChange, + entryId, onRemove, - onPatchProps, }: { - entry: RenderStackEntry; - onToggleVisible: (entryId: string, visible: boolean) => void; - onOpacityChange: (entryId: string, opacity: number) => void; + entryId: string; onRemove: (entryId: string) => void; - onPatchProps: (entryId: string, props: Record) => void; }) { + const { entry, patchEntry, patchProps } = useRenderStackEntry(entryId); const { attributes, listeners, @@ -184,11 +148,11 @@ const SortableLayerAccordion = observer(function SortableLayerAccordion({ transform, transition, isDragging, - } = useSortable({ id: entry.id }); - const [isHovered, setIsHovered] = useState(false); + } = useSortable({ id: entryId }); + + if (!entry) return null; - const opacity = - typeof entry.props.opacity === "number" ? entry.props.opacity : 1; + const opacity = typeof entry.props.opacity === "number" ? entry.props.opacity : 1; const supportsOpacity = entry.kind === "spatial"; const style = { @@ -204,15 +168,13 @@ const SortableLayerAccordion = observer(function SortableLayerAccordion({ disableGutters sx={{ width: "100%", display: "block" }} defaultExpanded={entry.kind === "spatial"} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > }> {isRemovableRenderStackEntry(entry) && ( { event.stopPropagation(); - onRemove(entry.id); + onRemove(entryId); }} aria-label="remove layer" size="small" @@ -220,8 +182,6 @@ const SortableLayerAccordion = observer(function SortableLayerAccordion({ position: "absolute", right: "-18px", top: "-18px", - opacity: isHovered ? 1 : 0, - transition: "opacity 0.3s", }} > @@ -240,7 +200,9 @@ const SortableLayerAccordion = observer(function SortableLayerAccordion({ size="small" checked={entry.visible} onClick={(event) => event.stopPropagation()} - onChange={(event) => onToggleVisible(entry.id, event.target.checked)} + onChange={(event) => + patchEntry({ visible: event.target.checked }) + } /> {renderStackEntryDisplayName(entry)} @@ -258,7 +220,7 @@ const SortableLayerAccordion = observer(function SortableLayerAccordion({ value={opacity} onChange={(_, value) => { if (typeof value === "number") { - onOpacityChange(entry.id, value); + patchProps({ opacity: value }); } }} /> @@ -267,7 +229,7 @@ const SortableLayerAccordion = observer(function SortableLayerAccordion({
- + ); @@ -278,6 +240,7 @@ const SpatialLayerDialogComponent = observer(() => { const config = useConfig(); const { spatialData } = useSpatialData(); const stack = config.renderStack; + const mutateStack = useRenderStackMutation(); const sensors = useSensors( useSensor(PointerSensor, { @@ -285,25 +248,6 @@ const SpatialLayerDialogComponent = observer(() => { }), ); - const updateStack = useCallback( - (mutator: (current: RenderStack) => void) => { - runInAction(() => { - if (!config.renderStack) return; - mutator(config.renderStack); - }); - }, - [config], - ); - - const onPatchProps = useCallback( - (entryId: string, props: Record) => { - updateStack((current) => { - patchRenderStackEntry(current, entryId, { props }); - }); - }, - [updateStack], - ); - const insertOptions = useMemo(() => { if (!stack || !spatialData) return []; const options: InsertOption[] = []; @@ -344,17 +288,17 @@ const SpatialLayerDialogComponent = observer(() => { const oldIndex = ids.indexOf(String(active.id)); const newIndex = ids.indexOf(String(over.id)); if (oldIndex === -1 || newIndex === -1) return; - updateStack((current) => { + mutateStack((current) => { reorderRenderStackEntries(current, oldIndex, newIndex); }); }, - [stack, updateStack], + [mutateStack, stack], ); const onInsert = useCallback( (option: InsertOption | null) => { if (!option) return; - updateStack((current) => { + mutateStack((current) => { if (option.kind === "spatial") { insertSpatialRenderStackEntry( current, @@ -367,7 +311,7 @@ const SpatialLayerDialogComponent = observer(() => { } }); }, - [updateStack], + [mutateStack], ); if (!stack?.entries.length) { @@ -392,28 +336,15 @@ const SpatialLayerDialogComponent = observer(() => { > - {stack.entries.map((entry) => ( + {entryIds.map((entryId) => ( { - updateStack((current) => { - patchRenderStackEntry(current, entryId, { visible }); - }); - }} - onOpacityChange={(entryId, opacity) => { - updateStack((current) => { - patchRenderStackEntry(current, entryId, { - props: { opacity }, - }); - }); - }} - onRemove={(entryId) => { - updateStack((current) => { - removeRenderStackEntry(current, entryId); + key={entryId} + entryId={entryId} + onRemove={(id) => { + mutateStack((current) => { + removeRenderStackEntry(current, id); }); }} - onPatchProps={onPatchProps} /> ))} diff --git a/src/react/spatialdata/host_layer_resolver.ts b/src/react/spatialdata/host_layer_resolver.ts index d36f04590..a650a9101 100644 --- a/src/react/spatialdata/host_layer_resolver.ts +++ b/src/react/spatialdata/host_layer_resolver.ts @@ -1,4 +1,4 @@ -import type { RenderStackHostEntry } from "@spatialdata/layers"; +import type { RenderStack, RenderStackHostEntry } from "@spatialdata/layers"; import type { Layer } from "@deck.gl/core"; import { deckIdFromHostLayerId, type DeckOverlayId } from "./host_overlay_ids"; @@ -12,3 +12,52 @@ export function createMdvHostLayerResolver(overlays: MdvDeckOverlayLayers) { return overlays[deckId] ?? null; }; } + +export function renderStackHostFingerprint(stack: RenderStack | undefined): string { + if (!stack) return ""; + return stack.entries + .filter((entry): entry is RenderStackHostEntry => entry.kind === "host") + .map((entry) => `${entry.id}:${entry.visible}`) + .join("|"); +} + +const hostLayerCloneCache = new WeakMap>(); + +function cloneHostLayer(source: Layer, entryId: string): Layer { + let clonesForSource = hostLayerCloneCache.get(source); + if (!clonesForSource) { + clonesForSource = new Map(); + hostLayerCloneCache.set(source, clonesForSource); + } + let clone = clonesForSource.get(entryId); + if (!clone) { + clone = source.clone({ id: entryId }) as Layer; + clonesForSource.set(entryId, clone); + } + return clone; +} + +/** + * Resolve visible host stack entries to deck layers, cloning each source layer once per + * entry id. Cosmetic spatial-layer edits (e.g. opacity) can then refresh the render + * stack shell without re-cloning scatter/gate overlays on every frame. + */ +export function resolveCachedHostDeckLayers( + stack: RenderStack | undefined, + resolver: (entry: RenderStackHostEntry) => Layer | Layer[] | null | undefined, +): Layer[] { + if (!stack) return []; + + const layers: Layer[] = []; + for (const entry of stack.entries) { + if (entry.kind !== "host" || !entry.visible) continue; + const resolved = resolver(entry); + if (!resolved) continue; + const sources = Array.isArray(resolved) ? resolved : [resolved]; + for (const source of sources) { + if (!source) continue; + layers.push(cloneHostLayer(source, entry.id)); + } + } + return layers; +} diff --git a/src/react/spatialdata/render_stack_entry_state.ts b/src/react/spatialdata/render_stack_entry_state.ts new file mode 100644 index 000000000..a3f24fadd --- /dev/null +++ b/src/react/spatialdata/render_stack_entry_state.ts @@ -0,0 +1,100 @@ +import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; +import type { LayerConfig } from "@spatialdata/vis"; +import { runInAction } from "mobx"; +import { useCallback } from "react"; + +import type { SpatialDataMdvReact, SpatialDataMdvReactConfig } from "@/react/components/SpatialDataMDVReact"; +import { useChart } from "@/react/context"; +import { useConfig } from "@/react/hooks"; +import { patchRenderStackEntry } from "./render_stack_mutations"; + +/** + * MobX render-stack control model for the layer dialog and viewer. + * + * - `config.renderStack` stays a stable object; entries and props are patched in place. + * - `chart.renderStackGeneration` bumps on commit so the canvas can refresh without + * replacing `config.renderStack` (which would re-render the whole dialog list). + * - UI rows use `useRenderStackEntry(entryId)` inside `observer` components so each row + * subscribes only to its own entry. Panels receive plain derived props at the boundary. + * + * Call hooks only from `observer` components. + */ + +export function spatialEntryAsLayerConfig( + entry: Extract, +): LayerConfig { + return { + id: entry.id, + type: entry.source.elementType, + elementKey: entry.source.elementKey, + visible: entry.visible, + opacity: typeof entry.props.opacity === "number" ? entry.props.opacity : 1, + ...entry.props, + } as LayerConfig; +} + +export function useRenderStackMutation() { + const config = useConfig(); + const chart = useChart(); + + return useCallback( + (mutator: (stack: RenderStack) => void) => { + runInAction(() => { + if (!config.renderStack) return; + mutator(config.renderStack); + chart.bumpRenderStackGeneration(); + }); + }, + [chart, config], + ); +} + +export function useRenderStackEntry(entryId: string) { + const config = useConfig(); + const chart = useChart(); + const entry = config.renderStack?.entries.find((item) => item.id === entryId); + + const patchEntry = useCallback( + (patch: Parameters[2]) => { + runInAction(() => { + if (!config.renderStack) return; + patchRenderStackEntry(config.renderStack, entryId, patch); + chart.bumpRenderStackGeneration(); + }); + }, + [chart, config, entryId], + ); + + const patchProps = useCallback( + (props: Record) => { + patchEntry({ props }); + }, + [patchEntry], + ); + + const patchLayer = useCallback( + (updates: Partial) => { + const { visible, ...rest } = updates as Partial & { + visible?: boolean; + }; + const patch: Parameters[2] = {}; + if (visible !== undefined) patch.visible = visible; + if (Object.keys(rest).length > 0) { + patch.props = rest as Record; + } + patchEntry(patch); + }, + [patchEntry], + ); + + const layer = + entry?.kind === "spatial" ? spatialEntryAsLayerConfig(entry) : null; + + return { + entry, + layer, + patchEntry, + patchProps, + patchLayer, + }; +} diff --git a/src/react/spatialdata/render_stack_layer_inputs.ts b/src/react/spatialdata/render_stack_layer_inputs.ts new file mode 100644 index 000000000..4988c00df --- /dev/null +++ b/src/react/spatialdata/render_stack_layer_inputs.ts @@ -0,0 +1,53 @@ +import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; +import type { LayerConfig } from "@spatialdata/vis"; +import { renderStackToLayerInputs, type RenderStackLayerInputs } from "@spatialdata/vis"; + +import { spatialEntryAsLayerConfig } from "./render_stack_entry_state"; + +export type RenderStackLayerInputsCache = { + layers: Record; + layerOrder: string[]; +}; + +export function createRenderStackLayerInputsCache(): RenderStackLayerInputsCache { + return { layers: {}, layerOrder: [] }; +} + +/** + * Keep a stable `layers` object identity across cosmetic edits so + * `useLayerData` does not re-enter async geometry loads. Mutate layer configs + * in place when props change; only replace/add/remove entries structurally. + */ +export function syncRenderStackLayerInputs( + stack: RenderStack, + cache: RenderStackLayerInputsCache, +): RenderStackLayerInputs { + const nextIds = new Set(); + + for (const entry of stack.entries) { + if (entry.kind !== "spatial") continue; + nextIds.add(entry.id); + const nextConfig = spatialEntryAsLayerConfig( + entry as Extract, + ); + const existing = cache.layers[entry.id]; + if (existing) { + Object.assign(existing, nextConfig); + } else { + cache.layers[entry.id] = nextConfig; + } + } + + for (const id of Object.keys(cache.layers)) { + if (!nextIds.has(id)) { + delete cache.layers[id]; + } + } + + const nextOrder = renderStackToLayerInputs(stack).layerOrder; + if (nextOrder.join("\0") !== cache.layerOrder.join("\0")) { + cache.layerOrder = nextOrder; + } + + return { layers: cache.layers, layerOrder: cache.layerOrder }; +} diff --git a/src/react/spatialdata/render_stack_mutations.ts b/src/react/spatialdata/render_stack_mutations.ts index 1a63d732c..184b0e111 100644 --- a/src/react/spatialdata/render_stack_mutations.ts +++ b/src/react/spatialdata/render_stack_mutations.ts @@ -29,10 +29,20 @@ export function patchRenderStackEntry( entry.visible = patch.visible; } if (patch.props) { - entry.props = { ...entry.props, ...patch.props }; + for (const [key, value] of Object.entries(patch.props)) { + entry.props[key] = value; + } } } +/** New stack shell, same entry objects — invalidates viewer `renderStack` memo without cloning entries. */ +export function refreshRenderStackShell(stack: RenderStack): RenderStack { + return { + schemaVersion: stack.schemaVersion, + entries: stack.entries, + }; +} + export function insertSpatialRenderStackEntry( stack: RenderStack, elementType: RenderStackSpatialElementType, From e9195aa486989ed78798a7da266b4fc7fe5197f1 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 19 Jun 2026 16:47:51 +0100 Subject: [PATCH 05/47] plan for local refactor --- docs/spatialdata-vis-integration.md | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/spatialdata-vis-integration.md b/docs/spatialdata-vis-integration.md index 0ff29ad16..ae68f9462 100644 --- a/docs/spatialdata-vis-integration.md +++ b/docs/spatialdata-vis-integration.md @@ -10,6 +10,46 @@ Living log for MDV ↔ `@spatialdata/*` integration. Architecture and phased roa MDV owns `chart.config.renderStack` (MobX observable). `SpatialCanvasViewer` is a controlled renderer: plain `renderStack` + `hostLayerResolver` at the React boundary. No parallel stack maps or periodic whole-stack snapshots. +The main integration concern is the MDV ↔ SpatialData.js render-stack boundary, not the local file count. MDV currently keeps a small adapter layer around that boundary to preserve object identity for spatial layer configs, cache cloned host deck layers, and force viewer refreshes without re-entering expensive geometry loads on cosmetic edits. + +For this PR, keep that adapter local to MDV and make its invariants explicit before proposing SpatialData.js API changes. A follow-up SpatialData.js design opportunity is a render-stack adapter/hook that accepts a mutable stack plus a version token and returns identity-stable layer inputs and host deck layers. + +Call the local boundary the **Render Stack Adapter**. It owns conversion from MDV's MobX-backed `config.renderStack` into SpatialData.js viewer inputs, including layer-config identity preservation, host deck layer clone caching, and explicit version-token refreshes. Viewer components should pass stack state into this adapter rather than coordinating cache invalidation, host fingerprints, and MobX observation directly. + +Expose the adapter primarily as a React hook, backed by small pure helpers for testable transforms. The hook owns refs, cache lifetimes, memo dependencies, and host-layer fingerprinting; the chart component consumes viewer-ready `layers`, `layerOrder`, and `deckLayers`. + +Keep the Render Stack Adapter read-side only. Stack editing remains in the render-stack control layer (`useRenderStackMutation`, `useRenderStackEntry`, insert/remove/reorder/default helpers), so config mutation behavior stays distinct from viewer input adaptation and performance caching. + +Keep layer panels type-specific for this PR. A later pass can consolidate repeated panel controls, but this pass focuses on state adapters and helper boundaries around render-stack control and viewer input adaptation. + +Organize `src/react/spatialdata` helpers around three responsibilities: + +- **Render Stack Adapter** — read-side hook for SpatialData.js viewer inputs; owns layer-input cache, host fingerprinting, host clone caching, and generation-based refreshes. +- **Render Stack Control** — MobX-facing mutation hooks plus pure edit operations; owns patch/insert/remove/reorder behavior for `config.renderStack`. +- **Render Stack Defaults** — stack creation, normalization, available spatial entries, and default layer props. Prefer "defaults" over "seed" in names because it describes the domain role more directly. + +Leave orthogonal helpers separate: host overlay IDs, view-state bridging, spatial feature tooltip formatting, and table association placeholders. + +Merge the current entry-state and mutation helpers into `render_stack_control.ts`, with pure stack operations first and MobX/React hooks second. This keeps all `config.renderStack` editing behavior under one named responsibility. + +Keep `spatialEntryAsLayerConfig` with the adapter/read-side conversion code, not with mutation/control. It projects a stack entry into SpatialData.js `LayerConfig`; control hooks may use it for UI convenience, but it is not part of stack editing semantics. + +Move host-layer fingerprinting and clone caching into the Render Stack Adapter. `host_overlay_ids.ts` remains the small vocabulary module for MDV overlay IDs, while the adapter owns the performance-sensitive mechanics that turn host stack entries into viewer-ready deck layers. + +Move `createMdvHostLayerResolver` into the Render Stack Adapter too, so the chart component imports one module for the render-stack read path. Keep `host_overlay_ids.ts` purely declarative: MDV overlay IDs, labels, and host-layer ID conversion helpers. + +Protect this refactor with a small number of focused Vitest unit tests for adapter/control/defaults behavior, especially object identity and cache invalidation. These tests should create useful friction against accidental performance regressions without exhaustively locking down implementation details. Prefer tests under `src/tests/react/spatialdata/`; defer Playwright coverage unless the user-visible dialog behavior changes. + +Keep the adapter mostly MobX-neutral: the caller passes the mutable stack plus an explicit version token, and the adapter returns identity-stable viewer inputs. The token may remain named `renderStackGeneration`; the important point is that it exists to avoid garbage churn from replacing/cloning render-stack objects on cosmetic edits. Mutation/control remains the MobX-aware layer. The token should be clearly marked as an intentional, narrow refresh signal, not as a general state-management pattern. + +Add a short code comment next to `renderStackGeneration` explaining that it is an intentional version token for in-place render-stack edits and exists to refresh adapter outputs without replacing stack objects or causing layer/data churn. + +Do not leave compatibility re-export files for old helper names. These helpers are branch-local implementation details, so update imports to the new module boundaries and delete dead files rather than preserving aliases. + +Remove the unused `refreshRenderStackShell` helper. Replacing only the stack shell can be revisited later if it proves cleaner without reintroducing layer/data churn, but this pass uses the explicit narrow refresh token. + +Possible follow-up: consider publishing MDV to npm so SpatialData.js can exercise real MDV integration points without local worktree/link setup. This is an integration enabler, not part of the adapter refactor. + ## Initial PR scope | Commit stage | Status | From 7c9a12623be1033953e19c496efe549cd33febdc Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 19 Jun 2026 17:03:28 +0100 Subject: [PATCH 06/47] Consolidate spatial render stack adapters --- src/react/components/SpatialDataMDVReact.tsx | 6 +- .../SpatialDataMDVReactComponent.tsx | 47 +--- .../SpatialLayerDialogComponent.tsx | 6 +- src/react/spatialdata/host_layer_resolver.ts | 63 ------ src/react/spatialdata/render_stack_adapter.ts | 174 +++++++++++++++ ...entry_state.ts => render_stack_control.ts} | 118 ++++++++-- ...stack_seed.ts => render_stack_defaults.ts} | 0 .../spatialdata/render_stack_layer_inputs.ts | 53 ----- .../spatialdata/render_stack_mutations.ts | 97 --------- .../spatialdata/renderStackHelpers.test.ts | 204 ++++++++++++++++++ 10 files changed, 498 insertions(+), 270 deletions(-) delete mode 100644 src/react/spatialdata/host_layer_resolver.ts create mode 100644 src/react/spatialdata/render_stack_adapter.ts rename src/react/spatialdata/{render_stack_entry_state.ts => render_stack_control.ts} (51%) rename src/react/spatialdata/{render_stack_seed.ts => render_stack_defaults.ts} (100%) delete mode 100644 src/react/spatialdata/render_stack_layer_inputs.ts delete mode 100644 src/react/spatialdata/render_stack_mutations.ts create mode 100644 src/tests/react/spatialdata/renderStackHelpers.test.ts diff --git a/src/react/components/SpatialDataMDVReact.tsx b/src/react/components/SpatialDataMDVReact.tsx index 79b9ad32a..15572b935 100644 --- a/src/react/components/SpatialDataMDVReact.tsx +++ b/src/react/components/SpatialDataMDVReact.tsx @@ -46,7 +46,11 @@ class SpatialDataMdvReact extends BaseReactChart { vivStores: VivContextType; layerDialog?: SpatialLayerDialogReactWrapper; ignoreStateUpdate = false; - /** Bumped when render-stack entries/props change; canvas observer reads this. */ + /** + * Intentional version token for in-place renderStack edits. The adapter reads this + * so cosmetic edits can refresh viewer inputs without replacing stack objects and + * causing layer/data churn. + */ renderStackGeneration = 0; get viewerStore() { diff --git a/src/react/components/SpatialDataMDVReactComponent.tsx b/src/react/components/SpatialDataMDVReactComponent.tsx index e97be3133..40eb37757 100644 --- a/src/react/components/SpatialDataMDVReactComponent.tsx +++ b/src/react/components/SpatialDataMDVReactComponent.tsx @@ -1,4 +1,3 @@ -import type { RenderStack } from "@spatialdata/layers"; import { SpatialDataProvider, useSpatialData } from "@spatialdata/react"; import { SpatialCanvasViewer, @@ -16,18 +15,13 @@ import { getCombinedScatterTooltip } from "@/lib/scatterTooltip"; import type { FieldName } from "@/charts/charts"; import { createMdvHostLayerResolver, - renderStackHostFingerprint, - resolveCachedHostDeckLayers, + useRenderStackAdapter, type MdvDeckOverlayLayers, -} from "@/react/spatialdata/host_layer_resolver"; +} from "@/react/spatialdata/render_stack_adapter"; import { createDefaultRenderStack, normalizeRenderStack, -} from "@/react/spatialdata/render_stack_seed"; -import { - createRenderStackLayerInputsCache, - syncRenderStackLayerInputs, -} from "@/react/spatialdata/render_stack_layer_inputs"; +} from "@/react/spatialdata/render_stack_defaults"; import { toMdvViewState, toSpatialViewState } from "@/react/spatialdata/view_state_bridge"; import { formatSpatialFeatureTooltipHtml } from "@/react/spatialdata/spatial_feature_tooltip"; import { useOuterContainer } from "../screen_state"; @@ -66,15 +60,6 @@ function getSpatialDataUrl(region: SpatialRegionMetadata) { return getProjectURL(`spatial/${file}`); } -function observeSpatialRenderStack(stack: RenderStack | undefined) { - for (const entry of stack?.entries ?? []) { - if (entry.kind !== "spatial") continue; - void entry.id; - void entry.visible; - void entry.props; - } -} - const SpatialCanvasFromRenderStack = observer(function SpatialCanvasFromRenderStack({ spatialData, coordinateSystem, @@ -92,24 +77,14 @@ const SpatialCanvasFromRenderStack = observer(function SpatialCanvasFromRenderSt deckProps: Partial; onFeatureHover: (event: SpatialFeaturePickEvent) => void; }) { - const layerInputsCacheRef = useRef(createRenderStackLayerInputsCache()); const config = useConfig(); const chart = useChart(); const stack = config.renderStack; - const generation = chart.renderStackGeneration; - void generation; - observeSpatialRenderStack(stack); - - const layerInputs = useMemo(() => { - if (!stack) return { layers: {}, layerOrder: [] as string[] }; - return syncRenderStackLayerInputs(stack, layerInputsCacheRef.current); - }, [stack, generation]); - - const hostFingerprint = renderStackHostFingerprint(stack); - const hostDeckLayers = useMemo( - () => resolveCachedHostDeckLayers(stack, hostLayerResolver), - [hostFingerprint, hostLayerResolver], - ); + const { layers, layerOrder, deckLayers } = useRenderStackAdapter({ + stack, + generation: chart.renderStackGeneration, + hostLayerResolver, + }); if (!stack?.entries.length) { return
Preparing layer stack…
; @@ -119,9 +94,9 @@ const SpatialCanvasFromRenderStack = observer(function SpatialCanvasFromRenderSt ; - -export function createMdvHostLayerResolver(overlays: MdvDeckOverlayLayers) { - return (entry: RenderStackHostEntry): Layer | null => { - const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); - if (!deckId) return null; - return overlays[deckId] ?? null; - }; -} - -export function renderStackHostFingerprint(stack: RenderStack | undefined): string { - if (!stack) return ""; - return stack.entries - .filter((entry): entry is RenderStackHostEntry => entry.kind === "host") - .map((entry) => `${entry.id}:${entry.visible}`) - .join("|"); -} - -const hostLayerCloneCache = new WeakMap>(); - -function cloneHostLayer(source: Layer, entryId: string): Layer { - let clonesForSource = hostLayerCloneCache.get(source); - if (!clonesForSource) { - clonesForSource = new Map(); - hostLayerCloneCache.set(source, clonesForSource); - } - let clone = clonesForSource.get(entryId); - if (!clone) { - clone = source.clone({ id: entryId }) as Layer; - clonesForSource.set(entryId, clone); - } - return clone; -} - -/** - * Resolve visible host stack entries to deck layers, cloning each source layer once per - * entry id. Cosmetic spatial-layer edits (e.g. opacity) can then refresh the render - * stack shell without re-cloning scatter/gate overlays on every frame. - */ -export function resolveCachedHostDeckLayers( - stack: RenderStack | undefined, - resolver: (entry: RenderStackHostEntry) => Layer | Layer[] | null | undefined, -): Layer[] { - if (!stack) return []; - - const layers: Layer[] = []; - for (const entry of stack.entries) { - if (entry.kind !== "host" || !entry.visible) continue; - const resolved = resolver(entry); - if (!resolved) continue; - const sources = Array.isArray(resolved) ? resolved : [resolved]; - for (const source of sources) { - if (!source) continue; - layers.push(cloneHostLayer(source, entry.id)); - } - } - return layers; -} diff --git a/src/react/spatialdata/render_stack_adapter.ts b/src/react/spatialdata/render_stack_adapter.ts new file mode 100644 index 000000000..0482a9d0e --- /dev/null +++ b/src/react/spatialdata/render_stack_adapter.ts @@ -0,0 +1,174 @@ +import type { Layer } from "@deck.gl/core"; +import type { + RenderStack, + RenderStackEntry, + RenderStackHostEntry, +} from "@spatialdata/layers"; +import { + layerConfig, + renderStackToLayerInputs, + type LayerConfig, + type RenderStackLayerInputs, +} from "@spatialdata/vis"; +import { useMemo, useRef } from "react"; + +import { deckIdFromHostLayerId, type DeckOverlayId } from "./host_overlay_ids"; + +export type MdvDeckOverlayLayers = Record; + +export type RenderStackLayerInputsCache = { + layers: Record; + layerOrder: string[]; +}; + +export function createRenderStackLayerInputsCache(): RenderStackLayerInputsCache { + return { layers: {}, layerOrder: [] }; +} + +export function createMdvHostLayerResolver(overlays: MdvDeckOverlayLayers) { + return (entry: RenderStackHostEntry): Layer | null => { + const deckId = deckIdFromHostLayerId(entry.source.hostLayerId); + if (!deckId) return null; + return overlays[deckId] ?? null; + }; +} + +export function spatialEntryAsLayerConfig( + entry: Extract, +): LayerConfig { + return layerConfig( + entry.source.elementType, + { + id: entry.id, + elementKey: entry.source.elementKey, + visible: entry.visible, + opacity: typeof entry.props.opacity === "number" ? entry.props.opacity : 1, + }, + entry.props, + ); +} + +/** + * Keep a stable `layers` object identity across cosmetic edits so + * `useLayerData` does not re-enter async geometry loads. Mutate layer configs + * in place when props change; only replace/add/remove entries structurally. + */ +export function syncRenderStackLayerInputs( + stack: RenderStack, + cache: RenderStackLayerInputsCache, +): RenderStackLayerInputs { + const nextIds = new Set(); + + for (const entry of stack.entries) { + if (entry.kind !== "spatial") continue; + nextIds.add(entry.id); + const nextConfig = spatialEntryAsLayerConfig(entry); + const existing = cache.layers[entry.id]; + if (existing) { + Object.assign(existing, nextConfig); + } else { + cache.layers[entry.id] = nextConfig; + } + } + + for (const id of Object.keys(cache.layers)) { + if (!nextIds.has(id)) { + delete cache.layers[id]; + } + } + + const nextOrder = renderStackToLayerInputs(stack).layerOrder; + if (nextOrder.join("\0") !== cache.layerOrder.join("\0")) { + cache.layerOrder = nextOrder; + } + + return { layers: cache.layers, layerOrder: cache.layerOrder }; +} + +function renderStackHostFingerprint(stack: RenderStack | undefined): string { + if (!stack) return ""; + return stack.entries + .filter((entry): entry is RenderStackHostEntry => entry.kind === "host") + .map((entry) => `${entry.id}:${entry.visible}`) + .join("|"); +} + +const hostLayerCloneCache = new WeakMap>(); + +function cloneHostLayer(source: Layer, entryId: string): Layer { + let clonesForSource = hostLayerCloneCache.get(source); + if (!clonesForSource) { + clonesForSource = new Map(); + hostLayerCloneCache.set(source, clonesForSource); + } + let clone = clonesForSource.get(entryId); + if (!clone) { + clone = source.clone({ id: entryId }) as Layer; + clonesForSource.set(entryId, clone); + } + return clone; +} + +/** + * Resolve visible host stack entries to deck layers, cloning each source layer once per + * entry id. Cosmetic spatial-layer edits (e.g. opacity) can then refresh adapter outputs + * without re-cloning scatter/gate overlays on every frame. + */ +export function resolveCachedHostDeckLayers( + stack: RenderStack | undefined, + resolver: (entry: RenderStackHostEntry) => Layer | Layer[] | null | undefined, +): Layer[] { + if (!stack) return []; + + const layers: Layer[] = []; + for (const entry of stack.entries) { + if (entry.kind !== "host" || !entry.visible) continue; + const resolved = resolver(entry); + if (!resolved) continue; + const sources = Array.isArray(resolved) ? resolved : [resolved]; + for (const source of sources) { + if (!source) continue; + layers.push(cloneHostLayer(source, entry.id)); + } + } + return layers; +} + +function observeSpatialRenderStack(stack: RenderStack | undefined) { + for (const entry of stack?.entries ?? []) { + if (entry.kind !== "spatial") continue; + void entry.id; + void entry.visible; + void entry.props; + } +} + +export function useRenderStackAdapter({ + stack, + generation, + hostLayerResolver, +}: { + stack: RenderStack | undefined; + generation: number; + hostLayerResolver: ReturnType; +}) { + const layerInputsCacheRef = useRef(createRenderStackLayerInputsCache()); + observeSpatialRenderStack(stack); + + const layerInputs = useMemo(() => { + if (!stack) return { layers: {}, layerOrder: [] as string[] }; + return syncRenderStackLayerInputs(stack, layerInputsCacheRef.current); + }, [stack, generation]); + + const hostFingerprint = renderStackHostFingerprint(stack); + const deckLayers = useMemo( + () => resolveCachedHostDeckLayers(stack, hostLayerResolver), + [stack, hostFingerprint, hostLayerResolver], + ); + + return { + layers: layerInputs.layers, + layerOrder: layerInputs.layerOrder, + deckLayers, + }; +} diff --git a/src/react/spatialdata/render_stack_entry_state.ts b/src/react/spatialdata/render_stack_control.ts similarity index 51% rename from src/react/spatialdata/render_stack_entry_state.ts rename to src/react/spatialdata/render_stack_control.ts index a3f24fadd..7b42ebf4f 100644 --- a/src/react/spatialdata/render_stack_entry_state.ts +++ b/src/react/spatialdata/render_stack_control.ts @@ -1,12 +1,110 @@ -import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; +import type { + RenderStack, + RenderStackEntry, + RenderStackSpatialElementType, +} from "@spatialdata/layers"; import type { LayerConfig } from "@spatialdata/vis"; import { runInAction } from "mobx"; import { useCallback } from "react"; -import type { SpatialDataMdvReact, SpatialDataMdvReactConfig } from "@/react/components/SpatialDataMDVReact"; +import type { + SpatialDataMdvReact, + SpatialDataMdvReactConfig, +} from "@/react/components/SpatialDataMDVReact"; import { useChart } from "@/react/context"; import { useConfig } from "@/react/hooks"; -import { patchRenderStackEntry } from "./render_stack_mutations"; +import { + deckHostLayerId, + deckIdFromHostLayerId, + type DeckOverlayId, +} from "./host_overlay_ids"; +import { spatialEntryAsLayerConfig } from "./render_stack_adapter"; +import { spatialEntryId } from "./render_stack_defaults"; + +export function reorderRenderStackEntries( + stack: RenderStack, + fromIndex: number, + toIndex: number, +): void { + const next = [...stack.entries]; + const [moved] = next.splice(fromIndex, 1); + if (!moved) return; + next.splice(toIndex, 0, moved); + stack.entries = next; +} + +export function patchRenderStackEntry( + stack: RenderStack, + entryId: string, + patch: { + visible?: boolean; + props?: Record; + }, +): void { + const entry = stack.entries.find((item) => item.id === entryId); + if (!entry) return; + if (patch.visible !== undefined) { + entry.visible = patch.visible; + } + if (patch.props) { + for (const [key, value] of Object.entries(patch.props)) { + entry.props[key] = value; + } + } +} + +export function insertSpatialRenderStackEntry( + stack: RenderStack, + elementType: RenderStackSpatialElementType, + elementKey: string, + props: Record = {}, +): boolean { + const id = spatialEntryId(elementType, elementKey); + if (stack.entries.some((entry) => entry.id === id)) { + return false; + } + stack.entries.push({ + kind: "spatial", + id, + visible: true, + source: { elementType, elementKey }, + props: { opacity: 1, ...props }, + }); + return true; +} + +export function insertHostRenderStackEntry( + stack: RenderStack, + deckId: DeckOverlayId, +): boolean { + const id = deckHostLayerId(deckId); + if (stack.entries.some((entry) => entry.id === id)) { + return false; + } + stack.entries.push({ + kind: "host", + id, + visible: true, + source: { hostLayerId: id }, + props: {}, + }); + return true; +} + +export function removeRenderStackEntry(stack: RenderStack, entryId: string): void { + stack.entries = stack.entries.filter((entry) => entry.id !== entryId); +} + +export function renderStackEntryIds(stack: RenderStack): string[] { + return stack.entries.map((entry) => entry.id); +} + +export function isRemovableRenderStackEntry(entry: RenderStackEntry): boolean { + if (entry.kind === "host") { + return deckIdFromHostLayerId(entry.source.hostLayerId) !== null; + } + return entry.kind === "spatial"; +} /** * MobX render-stack control model for the layer dialog and viewer. @@ -19,20 +117,6 @@ import { patchRenderStackEntry } from "./render_stack_mutations"; * * Call hooks only from `observer` components. */ - -export function spatialEntryAsLayerConfig( - entry: Extract, -): LayerConfig { - return { - id: entry.id, - type: entry.source.elementType, - elementKey: entry.source.elementKey, - visible: entry.visible, - opacity: typeof entry.props.opacity === "number" ? entry.props.opacity : 1, - ...entry.props, - } as LayerConfig; -} - export function useRenderStackMutation() { const config = useConfig(); const chart = useChart(); diff --git a/src/react/spatialdata/render_stack_seed.ts b/src/react/spatialdata/render_stack_defaults.ts similarity index 100% rename from src/react/spatialdata/render_stack_seed.ts rename to src/react/spatialdata/render_stack_defaults.ts diff --git a/src/react/spatialdata/render_stack_layer_inputs.ts b/src/react/spatialdata/render_stack_layer_inputs.ts deleted file mode 100644 index 4988c00df..000000000 --- a/src/react/spatialdata/render_stack_layer_inputs.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { RenderStack, RenderStackEntry } from "@spatialdata/layers"; -import type { LayerConfig } from "@spatialdata/vis"; -import { renderStackToLayerInputs, type RenderStackLayerInputs } from "@spatialdata/vis"; - -import { spatialEntryAsLayerConfig } from "./render_stack_entry_state"; - -export type RenderStackLayerInputsCache = { - layers: Record; - layerOrder: string[]; -}; - -export function createRenderStackLayerInputsCache(): RenderStackLayerInputsCache { - return { layers: {}, layerOrder: [] }; -} - -/** - * Keep a stable `layers` object identity across cosmetic edits so - * `useLayerData` does not re-enter async geometry loads. Mutate layer configs - * in place when props change; only replace/add/remove entries structurally. - */ -export function syncRenderStackLayerInputs( - stack: RenderStack, - cache: RenderStackLayerInputsCache, -): RenderStackLayerInputs { - const nextIds = new Set(); - - for (const entry of stack.entries) { - if (entry.kind !== "spatial") continue; - nextIds.add(entry.id); - const nextConfig = spatialEntryAsLayerConfig( - entry as Extract, - ); - const existing = cache.layers[entry.id]; - if (existing) { - Object.assign(existing, nextConfig); - } else { - cache.layers[entry.id] = nextConfig; - } - } - - for (const id of Object.keys(cache.layers)) { - if (!nextIds.has(id)) { - delete cache.layers[id]; - } - } - - const nextOrder = renderStackToLayerInputs(stack).layerOrder; - if (nextOrder.join("\0") !== cache.layerOrder.join("\0")) { - cache.layerOrder = nextOrder; - } - - return { layers: cache.layers, layerOrder: cache.layerOrder }; -} diff --git a/src/react/spatialdata/render_stack_mutations.ts b/src/react/spatialdata/render_stack_mutations.ts deleted file mode 100644 index 184b0e111..000000000 --- a/src/react/spatialdata/render_stack_mutations.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { RenderStack, RenderStackEntry, RenderStackSpatialElementType } from "@spatialdata/layers"; - -import { deckHostLayerId, deckIdFromHostLayerId, type DeckOverlayId } from "./host_overlay_ids"; -import { spatialEntryId } from "./render_stack_seed"; - -export function reorderRenderStackEntries( - stack: RenderStack, - fromIndex: number, - toIndex: number, -): void { - const next = [...stack.entries]; - const [moved] = next.splice(fromIndex, 1); - if (!moved) return; - next.splice(toIndex, 0, moved); - stack.entries = next; -} - -export function patchRenderStackEntry( - stack: RenderStack, - entryId: string, - patch: { - visible?: boolean; - props?: Record; - }, -): void { - const entry = stack.entries.find((item) => item.id === entryId); - if (!entry) return; - if (patch.visible !== undefined) { - entry.visible = patch.visible; - } - if (patch.props) { - for (const [key, value] of Object.entries(patch.props)) { - entry.props[key] = value; - } - } -} - -/** New stack shell, same entry objects — invalidates viewer `renderStack` memo without cloning entries. */ -export function refreshRenderStackShell(stack: RenderStack): RenderStack { - return { - schemaVersion: stack.schemaVersion, - entries: stack.entries, - }; -} - -export function insertSpatialRenderStackEntry( - stack: RenderStack, - elementType: RenderStackSpatialElementType, - elementKey: string, - props: Record = {}, -): boolean { - const id = spatialEntryId(elementType, elementKey); - if (stack.entries.some((entry) => entry.id === id)) { - return false; - } - stack.entries.push({ - kind: "spatial", - id, - visible: true, - source: { elementType, elementKey }, - props: { opacity: 1, ...props }, - }); - return true; -} - -export function insertHostRenderStackEntry( - stack: RenderStack, - deckId: DeckOverlayId, -): boolean { - const id = deckHostLayerId(deckId); - if (stack.entries.some((entry) => entry.id === id)) { - return false; - } - stack.entries.push({ - kind: "host", - id, - visible: true, - source: { hostLayerId: id }, - props: {}, - }); - return true; -} - -export function removeRenderStackEntry(stack: RenderStack, entryId: string): void { - stack.entries = stack.entries.filter((entry) => entry.id !== entryId); -} - -export function renderStackEntryIds(stack: RenderStack): string[] { - return stack.entries.map((entry) => entry.id); -} - -export function isRemovableRenderStackEntry(entry: RenderStackEntry): boolean { - if (entry.kind === "host") { - return deckIdFromHostLayerId(entry.source.hostLayerId) !== null; - } - return entry.kind === "spatial"; -} diff --git a/src/tests/react/spatialdata/renderStackHelpers.test.ts b/src/tests/react/spatialdata/renderStackHelpers.test.ts new file mode 100644 index 000000000..e1ca4f92b --- /dev/null +++ b/src/tests/react/spatialdata/renderStackHelpers.test.ts @@ -0,0 +1,204 @@ +import type { Layer } from "@deck.gl/core"; +import type { SpatialData } from "@spatialdata/core"; +import { + RENDER_STACK_SCHEMA_VERSION, + type RenderStack, + type RenderStackEntry, +} from "@spatialdata/layers"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@spatialdata/vis", () => ({ + layerConfig: ( + type: string, + base: Record, + props: Record = {}, + ) => ({ ...props, ...base, type }), + renderStackToLayerInputs: (stack: RenderStack) => ({ + layers: {}, + layerOrder: stack.entries + .filter((entry) => entry.kind === "spatial") + .map((entry) => entry.id), + }), +})); + +vi.mock("@/react/context", () => ({ + useChart: vi.fn(), +})); + +vi.mock("@/react/hooks", () => ({ + useConfig: vi.fn(), +})); + +import { + createRenderStackLayerInputsCache, + resolveCachedHostDeckLayers, + syncRenderStackLayerInputs, +} from "@/react/spatialdata/render_stack_adapter"; +import { + createDefaultRenderStack, + normalizeRenderStack, +} from "@/react/spatialdata/render_stack_defaults"; +import { + patchRenderStackEntry, + removeRenderStackEntry, +} from "@/react/spatialdata/render_stack_control"; +import { deckHostLayerId } from "@/react/spatialdata/host_overlay_ids"; + +type SpatialEntry = Extract; +type HostEntry = Extract; + +function spatialEntry({ + id, + elementKey, + props = {}, + elementType = "image", +}: { + id: string; + elementKey: string; + props?: Record; + elementType?: SpatialEntry["source"]["elementType"]; +}): SpatialEntry { + return { + kind: "spatial", + id, + visible: true, + source: { elementType, elementKey }, + props: { opacity: 1, ...props }, + }; +} + +function hostEntry(id: string, visible = true): HostEntry { + return { + kind: "host", + id, + visible, + source: { hostLayerId: id }, + props: {}, + }; +} + +function renderStack(entries: RenderStackEntry[]): RenderStack { + return { + schemaVersion: RENDER_STACK_SCHEMA_VERSION, + entries, + }; +} + +function fakeDeckLayer(id: string) { + const clonedLayer = { id: `${id}-clone` } as Layer; + const clone = vi.fn(() => clonedLayer); + const sourceLayer = { id, clone } as unknown as Layer; + return { clonedLayer, clone, sourceLayer }; +} + +function fakeSpatialData(): SpatialData { + return { + images: { + imageA: { coordinateSystems: ["global"] }, + imageB: { coordinateSystems: ["global"] }, + }, + shapes: {}, + points: {}, + labels: {}, + } as unknown as SpatialData; +} + +describe("render stack adapter", () => { + test("keeps layer object identity across cosmetic edits", () => { + const imageEntry = spatialEntry({ id: "image-a", elementKey: "imageA" }); + const stack = renderStack([imageEntry]); + const cache = createRenderStackLayerInputsCache(); + + const first = syncRenderStackLayerInputs(stack, cache); + const layers = first.layers; + const layer = first.layers["image-a"]; + + imageEntry.props.opacity = 0.25; + const second = syncRenderStackLayerInputs(stack, cache); + + expect(second.layers).toBe(layers); + expect(second.layers["image-a"]).toBe(layer); + expect(second.layers["image-a"]?.opacity).toBe(0.25); + }); + + test("removes deleted spatial layers and updates layer order", () => { + const imageA = spatialEntry({ id: "image-a", elementKey: "imageA" }); + const imageB = spatialEntry({ id: "image-b", elementKey: "imageB" }); + const stack = renderStack([imageA, imageB]); + const cache = createRenderStackLayerInputsCache(); + + expect(syncRenderStackLayerInputs(stack, cache).layerOrder).toEqual([ + "image-a", + "image-b", + ]); + + removeRenderStackEntry(stack, "image-a"); + const next = syncRenderStackLayerInputs(stack, cache); + + expect(next.layers["image-a"]).toBeUndefined(); + expect(next.layerOrder).toEqual(["image-b"]); + }); + + test("resolves only visible host entries and reuses host clones", () => { + const visibleHostId = deckHostLayerId("scatter"); + const hiddenHostId = deckHostLayerId("selection"); + const stack = renderStack([ + hostEntry(visibleHostId), + hostEntry(hiddenHostId, false), + ]); + const { clone, clonedLayer, sourceLayer } = fakeDeckLayer("scatter"); + const resolver = vi.fn(() => sourceLayer); + + const first = resolveCachedHostDeckLayers(stack, resolver); + const second = resolveCachedHostDeckLayers(stack, resolver); + + expect(first).toEqual([clonedLayer]); + expect(second).toEqual([clonedLayer]); + expect(first[0]).toBe(second[0]); + expect(clone).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledTimes(2); + }); +}); + +describe("render stack control", () => { + test("patches entry props without replacing unrelated entries", () => { + const imageA = spatialEntry({ id: "image-a", elementKey: "imageA" }); + const imageB = spatialEntry({ id: "image-b", elementKey: "imageB" }); + const stack = renderStack([imageA, imageB]); + + patchRenderStackEntry(stack, "image-a", { + visible: false, + props: { opacity: 0.5 }, + }); + + expect(stack.entries[0]).toBe(imageA); + expect(stack.entries[1]).toBe(imageB); + expect(imageA.visible).toBe(false); + expect(imageA.props.opacity).toBe(0.5); + expect(imageB.props.opacity).toBe(1); + }); +}); + +describe("render stack defaults", () => { + test("normalizes defaults without replacing saved entries", () => { + const savedImage = spatialEntry({ + id: "spatialdata-image-imageB", + elementKey: "imageB", + props: { opacity: 0.4 }, + }); + const savedStack = renderStack([savedImage]); + + const normalized = normalizeRenderStack( + savedStack, + fakeSpatialData(), + "global", + ); + const defaultStack = createDefaultRenderStack(fakeSpatialData(), "global"); + + expect(normalized.entries[0]).toBe(savedImage); + expect(normalized.entries.map((entry) => entry.id)).toEqual([ + "spatialdata-image-imageB", + ...defaultStack.entries.map((entry) => entry.id), + ]); + }); +}); From 3b7b6ac9b0509cae221a086e4185bbb434687836 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Mon, 22 Jun 2026 09:42:39 +0100 Subject: [PATCH 07/47] add explicit zarrextra dependency and change version specifiers for sd.js --- package.json | 9 +++++---- pnpm-lock.yaml | 13 ++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index a31640bc0..527f41c30 100644 --- a/package.json +++ b/package.json @@ -95,10 +95,10 @@ "@radix-ui/react-radio-group": "^1.1.3", "@react-spring/web": "^10.0.3", "@slickgrid-universal/common": "10.6.0", - "@spatialdata/core": "npm:@spatialdata/core@next", - "@spatialdata/layers": "npm:@spatialdata/layers@next", - "@spatialdata/react": "npm:@spatialdata/react@next", - "@spatialdata/vis": "npm:@spatialdata/vis@next", + "@spatialdata/core": "^0.2.0", + "@spatialdata/layers": "^0.2.0", + "@spatialdata/react": "^0.2.0", + "@spatialdata/vis": "^0.2.0", "@tanstack/react-query": "^5.80.0", "@tanstack/react-virtual": "^3.11.1", "@turf/clone": "6.5.0", @@ -175,6 +175,7 @@ "use-debounce": "^10.0.2", "uuid": "^11.1.1", "wordcloud": "^1.2.2", + "zarrextra": "^0.2.0", "zod": "^3.25.49", "zustand": "^5.0.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb6ed8462..ab4810bca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,16 +120,16 @@ importers: specifier: 10.6.0 version: 10.6.0 '@spatialdata/core': - specifier: npm:@spatialdata/core@next + specifier: ^0.2.0 version: 0.2.0 '@spatialdata/layers': - specifier: npm:@spatialdata/layers@next + specifier: ^0.2.0 version: 0.2.0(4eff8b0fade424b0171a85a804c7311a) '@spatialdata/react': - specifier: npm:@spatialdata/react@next + specifier: ^0.2.0 version: 0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) '@spatialdata/vis': - specifier: npm:@spatialdata/vis@next + specifier: ^0.2.0 version: 0.2.0(5fed9fb1d070481f396b27a4e9a53f69) '@tanstack/react-query': specifier: ^5.80.0 @@ -359,6 +359,9 @@ importers: wordcloud: specifier: ^1.2.2 version: 1.2.2 + zarrextra: + specifier: ^0.2.0 + version: 0.2.0 zod: specifier: ^3.25.49 version: 3.25.49 @@ -11077,7 +11080,7 @@ snapshots: geotiff@2.1.4-beta.0: dependencies: - '@petamoriken/float16': 3.9.2 + '@petamoriken/float16': 3.9.3 lerc: 3.0.0 pako: 2.1.0 parse-headers: 2.0.6 From ea985811378b15ad0f342ea6419af7736b5193f5 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Mon, 22 Jun 2026 13:23:53 +0100 Subject: [PATCH 08/47] bump spatialdata.js versions for fixed codec-worker build --- package.json | 10 ++++---- pnpm-lock.yaml | 70 +++++++++++++++++++++++++------------------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index 527f41c30..f7175c542 100644 --- a/package.json +++ b/package.json @@ -95,10 +95,10 @@ "@radix-ui/react-radio-group": "^1.1.3", "@react-spring/web": "^10.0.3", "@slickgrid-universal/common": "10.6.0", - "@spatialdata/core": "^0.2.0", - "@spatialdata/layers": "^0.2.0", - "@spatialdata/react": "^0.2.0", - "@spatialdata/vis": "^0.2.0", + "@spatialdata/core": "^0.2.2", + "@spatialdata/layers": "^0.2.2", + "@spatialdata/react": "^0.2.2", + "@spatialdata/vis": "^0.2.2", "@tanstack/react-query": "^5.80.0", "@tanstack/react-virtual": "^3.11.1", "@turf/clone": "6.5.0", @@ -175,7 +175,7 @@ "use-debounce": "^10.0.2", "uuid": "^11.1.1", "wordcloud": "^1.2.2", - "zarrextra": "^0.2.0", + "zarrextra": "^0.2.2", "zod": "^3.25.49", "zustand": "^5.0.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab4810bca..fcb8a0730 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,17 +120,17 @@ importers: specifier: 10.6.0 version: 10.6.0 '@spatialdata/core': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.2.2 + version: 0.2.2 '@spatialdata/layers': - specifier: ^0.2.0 - version: 0.2.0(4eff8b0fade424b0171a85a804c7311a) + specifier: ^0.2.2 + version: 0.2.2(4eff8b0fade424b0171a85a804c7311a) '@spatialdata/react': - specifier: ^0.2.0 - version: 0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) + specifier: ^0.2.2 + version: 0.2.2(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) '@spatialdata/vis': - specifier: ^0.2.0 - version: 0.2.0(5fed9fb1d070481f396b27a4e9a53f69) + specifier: ^0.2.2 + version: 0.2.2(5fed9fb1d070481f396b27a4e9a53f69) '@tanstack/react-query': specifier: ^5.80.0 version: 5.80.6(react@19.2.6) @@ -360,8 +360,8 @@ importers: specifier: ^1.2.2 version: 1.2.2 zarrextra: - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.2.2 + version: 0.2.2 zod: specifier: ^3.25.49 version: 3.25.49 @@ -2222,30 +2222,30 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@spatialdata/avivatorish@0.2.0': - resolution: {integrity: sha512-Dk9xR+RppJzDqExJC6IaHLSGZp81F6cLEFFEBUQViaCZe2yJW8kmZR3WpCaxa/6oc4yqNgJhEHSaUOqCsZZquA==} + '@spatialdata/avivatorish@0.2.2': + resolution: {integrity: sha512-ylK4t3YYo3Gx0vkuKgE2wQfd8FtnCvV3s974GsNpl4gNdvba1h14aNABX47Oux513ZDQbmbZ7mozJ2Rb5zvI7Q==} peerDependencies: react: '>=18 <20' react-dom: '>=18 <20' - '@spatialdata/core@0.2.0': - resolution: {integrity: sha512-Aa2My/6TYsDtJo67XKYN1VqqDz0zXSi+UcygeP95n8LRJe5Hq63kIZKDhvx+fEjVnCnCN+MIODmaRwbSN3+WvQ==} + '@spatialdata/core@0.2.2': + resolution: {integrity: sha512-Ngc8gAROMLLPUnhrJMUmSxCefresCfVKxjS/HXTXFYPs+jHBD81YuOMPPQIX/bUDSTeNyRWG9/8/YA0jwwueFg==} - '@spatialdata/layers@0.2.0': - resolution: {integrity: sha512-3ZKEszulUZAOhYAbDSy4sCYhDHZ7CeInNrMyTnbiG5Qe1mU82idv34PU69l6PPo7hPH4abql0kr0Qj/wtVMPmQ==} + '@spatialdata/layers@0.2.2': + resolution: {integrity: sha512-/3sMN7BvseJnd22VRdVzOM3yFmEANCHRn9AtX0iIzBWi+rJTkTtRYWpVrAbOONd2bQ1WetiqVHMIgSgG1JuE/Q==} peerDependencies: deck.gl: ~9.2.9 - '@spatialdata/react@0.2.0': - resolution: {integrity: sha512-//dK4chOaTwF/tN2EgY8xoHVXc3rOW+GgxJ/rL335V3V00CW2Uves0Ik2GJzQ2VKZ/aetNmPyMw483VthiMopg==} + '@spatialdata/react@0.2.2': + resolution: {integrity: sha512-NrzXigqT4mpfScFenLWjFaH2NS/zsMhzdQgN2mMKmyEHqkKt9JKSUDglybHgo8IeCqvcprz8rR/cRdA8QbFqRg==} peerDependencies: anndata.js: ^0.0.2 react: '>=18 <20' react-dom: '>=18 <20' zarrita: ^0.7.1 - '@spatialdata/vis@0.2.0': - resolution: {integrity: sha512-+8psXCWHAM170tlS+AQmeIJ+6O0Jmba4cmEy8Z7XEJTg2obuI+xOPmlcukAMz3Da9jJxKqHKwoa282+myDAi9Q==} + '@spatialdata/vis@0.2.2': + resolution: {integrity: sha512-UNTYOwyJa9IofLN+pIa84J0o6fsBsjinyb2OSEpfAfuIgckoeqZr7sjAazkRqpw8naUsy3aWxd7sRVoNbZ+8OQ==} peerDependencies: anndata.js: ^0.0.2 react: '>=18 <20' @@ -6348,8 +6348,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zarrextra@0.2.0: - resolution: {integrity: sha512-dcos8Lvo3+GWqaWX8IA1i0wh6yXJSOGNuC3XFmIpjbkXrlDJfPZPWTOwZPJn2LRH0aZARmUlyxy+DgfVm+TJsQ==} + zarrextra@0.2.2: + resolution: {integrity: sha512-pis+O1tmZPnQYdzOddTTChQUhI/e1QpBXZ+XP4ybW+f8PaYX8D58N5xOKtGli7CnAbbYtdzwX1t1GgFGH4qN0g==} zarrita@0.5.1: resolution: {integrity: sha512-cyujP70BOl5DiXuLtM+0j9nq/pAov4SKXRYIQQOVnk2TfBg/jopX+FXLbqkq3ULOxFLB5AwkPbSp5KvZXoJrbQ==} @@ -8410,14 +8410,14 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@spatialdata/avivatorish@0.2.0(b1235b24ca004e234da3e4c035d43e3a)': + '@spatialdata/avivatorish@0.2.2(b1235b24ca004e234da3e4c035d43e3a)': dependencies: '@hms-dbmi/viv': 0.21.0(b55db1092d89286dd86a223febb17f6d) '@math.gl/core': 4.1.0 geotiff: 2.1.4-beta.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - zarrextra: 0.2.0 + zarrextra: 0.2.2 zustand: 5.0.14(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) transitivePeerDependencies: - '@deck.gl/core' @@ -8433,7 +8433,7 @@ snapshots: - immer - use-sync-external-store - '@spatialdata/core@0.2.0': + '@spatialdata/core@0.2.2': dependencies: '@math.gl/core': 4.1.0 '@zarrita/storage': 0.2.0 @@ -8441,11 +8441,11 @@ snapshots: apache-arrow: 17.0.0 ol: 10.9.0 parquet-wasm: 0.6.1 - zarrextra: 0.2.0 + zarrextra: 0.2.2 zarrita: 0.7.3 zod: 4.4.3 - '@spatialdata/layers@0.2.0(4eff8b0fade424b0171a85a804c7311a)': + '@spatialdata/layers@0.2.2(4eff8b0fade424b0171a85a804c7311a)': dependencies: '@deck.gl/core': 9.2.9 '@hms-dbmi/viv': 0.21.0(b55db1092d89286dd86a223febb17f6d) @@ -8464,24 +8464,24 @@ snapshots: - '@luma.gl/webgl' - react - '@spatialdata/react@0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3)': + '@spatialdata/react@0.2.2(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3)': dependencies: - '@spatialdata/core': 0.2.0 + '@spatialdata/core': 0.2.2 anndata.js: 0.0.2 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) zarrita: 0.7.3 - '@spatialdata/vis@0.2.0(5fed9fb1d070481f396b27a4e9a53f69)': + '@spatialdata/vis@0.2.2(5fed9fb1d070481f396b27a4e9a53f69)': dependencies: '@deck.gl/core': 9.2.9 '@hms-dbmi/viv': 0.21.0(b55db1092d89286dd86a223febb17f6d) '@luma.gl/core': 9.2.6 '@math.gl/core': 4.1.0 - '@spatialdata/avivatorish': 0.2.0(b1235b24ca004e234da3e4c035d43e3a) - '@spatialdata/core': 0.2.0 - '@spatialdata/layers': 0.2.0(4eff8b0fade424b0171a85a804c7311a) - '@spatialdata/react': 0.2.0(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) + '@spatialdata/avivatorish': 0.2.2(b1235b24ca004e234da3e4c035d43e3a) + '@spatialdata/core': 0.2.2 + '@spatialdata/layers': 0.2.2(4eff8b0fade424b0171a85a804c7311a) + '@spatialdata/react': 0.2.2(anndata.js@0.0.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zarrita@0.7.3) '@uidotdev/usehooks': 2.4.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@uiw/react-json-view': 2.0.0-alpha.39(@babel/runtime@7.29.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@vivjs/views': 0.21.0(52e8f89f20cb2eb0eb37426ecbefa25b) @@ -13306,7 +13306,7 @@ snapshots: yocto-queue@0.1.0: {} - zarrextra@0.2.0: + zarrextra@0.2.2: dependencies: zarrita: 0.7.3 zod: 4.4.3 From 2cbfc6a7629b7184cded393539d542331cf5b9d7 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 23 Jun 2026 11:10:05 +0100 Subject: [PATCH 09/47] enable chunk worker when using spatialdata --- .../components/SpatialDataMDVReactComponent.tsx | 2 ++ .../components/SpatialLayerDialogReactWrapper.tsx | 2 ++ src/react/spatialdata/ensureChunkWorker.ts | 13 +++++++++++++ vite.config.mts | 3 +++ 4 files changed, 20 insertions(+) create mode 100644 src/react/spatialdata/ensureChunkWorker.ts diff --git a/src/react/components/SpatialDataMDVReactComponent.tsx b/src/react/components/SpatialDataMDVReactComponent.tsx index e97be3133..b9e303f7c 100644 --- a/src/react/components/SpatialDataMDVReactComponent.tsx +++ b/src/react/components/SpatialDataMDVReactComponent.tsx @@ -29,6 +29,7 @@ import { syncRenderStackLayerInputs, } from "@/react/spatialdata/render_stack_layer_inputs"; import { toMdvViewState, toSpatialViewState } from "@/react/spatialdata/view_state_bridge"; +import { ensureChunkWorker } from "@/react/spatialdata/ensureChunkWorker"; import { formatSpatialFeatureTooltipHtml } from "@/react/spatialdata/spatial_feature_tooltip"; import { useOuterContainer } from "../screen_state"; import { useViewStateLink } from "../chartLinkHooks"; @@ -168,6 +169,7 @@ const SpatialDataMainChart = observer(() => { const rawRegion = useRegion(); const region = getSpatialRegionMetadata(rawRegion); const spatialDataUrl = region ? getSpatialDataUrl(region) : null; + ensureChunkWorker(); return ( diff --git a/src/react/components/SpatialLayerDialogReactWrapper.tsx b/src/react/components/SpatialLayerDialogReactWrapper.tsx index 546f6bbb0..dc7447863 100644 --- a/src/react/components/SpatialLayerDialogReactWrapper.tsx +++ b/src/react/components/SpatialLayerDialogReactWrapper.tsx @@ -4,6 +4,7 @@ import { BaseDialog } from "../../utilities/Dialog"; import { createEl } from "../../utilities/ElementsTyped"; import { createMdvPortal } from "@/react/react_utils"; import { getProjectURL } from "@/dataloaders/DataLoaderUtil"; +import { ensureChunkWorker } from "@/react/spatialdata/ensureChunkWorker"; import { ChartProvider } from "../context"; import { useRegion } from "../hooks"; import SpatialLayerDialogComponent from "./SpatialLayerDialogComponent"; @@ -21,6 +22,7 @@ function getSpatialDataUrl(region: unknown): string | undefined { const SpatialLayerDialogReact = observer(function SpatialLayerDialogReact() { const rawRegion = useRegion(); const spatialDataUrl = getSpatialDataUrl(rawRegion); + ensureChunkWorker(); return ( diff --git a/src/react/spatialdata/ensureChunkWorker.ts b/src/react/spatialdata/ensureChunkWorker.ts new file mode 100644 index 000000000..4b78ae1c6 --- /dev/null +++ b/src/react/spatialdata/ensureChunkWorker.ts @@ -0,0 +1,13 @@ +import { + enableWorkerChunkDecode, + type EnableWorkerChunkDecodeOptions, +} from "zarrextra/workers"; + +let chunkWorkerEnabled = false; + +/** Lazily enable zarrextra worker-pool chunk decode before spatial zarr reads. */ +export function ensureChunkWorker(options?: EnableWorkerChunkDecodeOptions) { + if (chunkWorkerEnabled) return; + enableWorkerChunkDecode(options); + chunkWorkerEnabled = true; +} diff --git a/vite.config.mts b/vite.config.mts index 7a63325cf..1fe554c1c 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -245,6 +245,9 @@ export default defineConfig(async (): Promise => { path.resolve(configDir, 'login_dev.html'), path.resolve(configDir, 'catalog_dev.html'), ], + // zarrextra/workers resolves codec-worker.js via import.meta.url; prebundling + // breaks that path and causes stale-cache 504s after package bumps. + exclude: ['zarrextra/workers'], }, } as UserConfig; }); From 759718eb251b43a3d7a60f56d558b743a77b4074 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 23 Jun 2026 13:15:23 +0100 Subject: [PATCH 10/47] Clean up spatialdata chart config ownership --- docs/spatialdata-vis-integration.md | 2 + src/react/components/SpatialDataMDVReact.tsx | 59 ++++++++++++------- .../SpatialDataMDVReactComponent.tsx | 8 +-- src/react/spatialdata/spatialdata_config.ts | 19 ++++++ .../spatialdata/spatialDataConfig.test.ts | 57 ++++++++++++++++++ 5 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 src/react/spatialdata/spatialdata_config.ts create mode 100644 src/tests/react/spatialdata/spatialDataConfig.test.ts diff --git a/docs/spatialdata-vis-integration.md b/docs/spatialdata-vis-integration.md index ae68f9462..add35ea41 100644 --- a/docs/spatialdata-vis-integration.md +++ b/docs/spatialdata-vis-integration.md @@ -10,6 +10,8 @@ Living log for MDV ↔ `@spatialdata/*` integration. Architecture and phased roa MDV owns `chart.config.renderStack` (MobX observable). `SpatialCanvasViewer` is a controlled renderer: plain `renderStack` + `hostLayerResolver` at the React boundary. No parallel stack maps or periodic whole-stack snapshots. +Chart root config owns chart/view concerns such as region, MDV overlay settings, and `viewState`. Render-stack entries own layer implementation concerns such as image channel settings (`entry.props.channels`). Viv channel/image settings are not part of the SpatialData chart root config. + The main integration concern is the MDV ↔ SpatialData.js render-stack boundary, not the local file count. MDV currently keeps a small adapter layer around that boundary to preserve object identity for spatial layer configs, cache cloned host deck layers, and force viewer refreshes without re-entering expensive geometry loads on cosmetic edits. For this PR, keep that adapter local to MDV and make its invariants explicit before proposing SpatialData.js API changes. A follow-up SpatialData.js design opportunity is a render-stack adapter/hook that accepts a mutable stack plus a version token and returns identity-stable layer inputs and host deck layers. diff --git a/src/react/components/SpatialDataMDVReact.tsx b/src/react/components/SpatialDataMDVReact.tsx index 15572b935..a7f17d8ce 100644 --- a/src/react/components/SpatialDataMDVReact.tsx +++ b/src/react/components/SpatialDataMDVReact.tsx @@ -5,21 +5,31 @@ import BaseChart from "../../charts/BaseChart"; import type DataStore from "@/datastore/DataStore"; import { allNumeric } from "@/lib/columnTypeHelpers"; import { g, toArray } from "@/lib/utils"; -import { scatterDefaults } from "../scatter_state"; +import { + type CategoryFilter, + type ScatterPlotConfig, + scatterDefaults, +} from "../scatter_state"; import { BaseReactChart } from "./BaseReactChart"; -import "../../charts/VivScatterPlot"; import { type VivContextType, - applyDefaultChannelState, createVivStores, } from "./avivatorish/state"; -import type { VivMdvReactConfig } from "./VivMDVReact"; import { getSharedScatterSettings } from "./sharedScatterSettings"; import SpatialLayerDialogReactWrapper from "./SpatialLayerDialogReactWrapper"; import SpatialDataChartRoot from "./SpatialDataMDVReactComponent"; - -export type SpatialDataMdvReactConfig = VivMdvReactConfig & { +import { + removeSpatialDataRootViv, + type SpatialDataSerializableViewState, + toSerializableSpatialDataViewState, +} from "@/react/spatialdata/spatialdata_config"; + +export type SpatialDataMdvReactConfig = ScatterPlotConfig & { + type: "SpatialDataMdvRegionReact"; + region: string; + background_filter: CategoryFilter; renderStack?: RenderStack; + viewState?: SpatialDataSerializableViewState | null; }; function adaptSpatialDataConfig( @@ -27,6 +37,7 @@ function adaptSpatialDataConfig( dataStore: DataStore, ) { const config = { ...scatterDefaults, ...originalConfig }; + removeSpatialDataRootViv(config); if (!dataStore.regions) { throw new Error("unexpected attempt to load spatial chart with no regions in datasource"); } @@ -37,7 +48,6 @@ function adaptSpatialDataConfig( config.contourParameter = dataStore.regions.region_field; } } - config.viv = applyDefaultChannelState(config.viv); return config; } @@ -90,7 +100,7 @@ class SpatialDataMdvReact extends BaseReactChart { colorBy?: (i: number) => [r: number, g: number, b: number]; - colorByColumn(col?: VivMdvReactConfig["color_by"]) { + colorByColumn(col?: SpatialDataMdvReactConfig["color_by"]) { if (!col) return this.colorByDefault(); this.config.color_by = col; //@ts-expect-error legacy color_by options are normalised at runtime by BaseChart. @@ -164,19 +174,10 @@ class SpatialDataMdvReact extends BaseReactChart { getConfig() { const config = super.getConfig(); + removeSpatialDataRootViv(config); if (this.vivStores) { const viewer = this.vivStores.viewerStore.getState(); - config.viv = { - ...config.viv, - viewerStore: { - viewState: viewer.viewState - ? { - target: viewer.viewState.target, - zoom: viewer.viewState.zoom, - } - : null, - }, - }; + config.viewState = toSerializableSpatialDataViewState(viewer.viewState); } if (this.config.renderStack) { config.renderStack = this.config.renderStack; @@ -186,9 +187,25 @@ class SpatialDataMdvReact extends BaseReactChart { } BaseChart.types.SpatialDataMdvRegionReact = { - ...BaseChart.types.VivMdvRegionReact, init: (config, dataStore, extraConfig) => { - BaseChart.types.VivMdvRegionReact.init?.(config, dataStore, extraConfig); + const regions = dataStore.regions; + if (!regions) { + throw new Error("unexpected attempt to initialise spatialdata chart with no regions in datasource"); + } + const regionKey = extraConfig.region; + config.color_by = regions.default_color; + const colorColumn = dataStore.columnIndex[regions.default_color]; + if (!allNumeric([colorColumn])) { + config.contourParameter = config.color_by; + } + config.param = [...regions.position_fields]; + config.background_filter = { + column: regions.region_field, + category: regionKey, + }; + config.color_legend = { display: false }; + config.region = regionKey; + config.title = regionKey; config.type = "SpatialDataMdvRegionReact"; }, class: SpatialDataMdvReact, diff --git a/src/react/components/SpatialDataMDVReactComponent.tsx b/src/react/components/SpatialDataMDVReactComponent.tsx index 40eb37757..434d4e4d0 100644 --- a/src/react/components/SpatialDataMDVReactComponent.tsx +++ b/src/react/components/SpatialDataMDVReactComponent.tsx @@ -4,7 +4,7 @@ import { type SpatialFeaturePickEvent, type ViewState as SpatialCanvasViewState, } from "@spatialdata/vis"; -import type { DeckGLProps, OrbitViewState, OrthographicViewState, PickingInfo } from "deck.gl"; +import type { DeckGLProps, OrthographicViewState, PickingInfo } from "deck.gl"; import { observer } from "mobx-react-lite"; import { runInAction } from "mobx"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -192,15 +192,15 @@ const SpatialDataViewer = observer( useViewStateLink(); useEffect(() => { - const saved = config.viv?.viewerStore?.viewState; - if (!saved || viewState) return; + const saved = config.viewState; + if (!saved?.target || viewState) return; viewerStore.setState({ viewState: { target: [...saved.target], zoom: saved.zoom, } as OrthographicViewState, }); - }, [config.viv?.viewerStore?.viewState, viewState, viewerStore]); + }, [config.viewState, viewState, viewerStore]); useEffect(() => { if (scatterProps.viewState) { diff --git a/src/react/spatialdata/spatialdata_config.ts b/src/react/spatialdata/spatialdata_config.ts new file mode 100644 index 000000000..22d04f95a --- /dev/null +++ b/src/react/spatialdata/spatialdata_config.ts @@ -0,0 +1,19 @@ +export type SpatialDataSerializableViewState = { + target: number[]; + zoom?: number; +}; + +export function removeSpatialDataRootViv(config: T): T { + delete (config as T & { viv?: unknown }).viv; + return config; +} + +export function toSerializableSpatialDataViewState( + viewState: SpatialDataSerializableViewState | null | undefined, +): SpatialDataSerializableViewState | null { + if (!viewState?.target) return null; + return { + target: viewState.target, + zoom: viewState.zoom, + }; +} diff --git a/src/tests/react/spatialdata/spatialDataConfig.test.ts b/src/tests/react/spatialdata/spatialDataConfig.test.ts new file mode 100644 index 000000000..462b3b7d8 --- /dev/null +++ b/src/tests/react/spatialdata/spatialDataConfig.test.ts @@ -0,0 +1,57 @@ +import { + RENDER_STACK_SCHEMA_VERSION, + type RenderStack, +} from "@spatialdata/layers"; +import { describe, expect, test } from "vitest"; + +import { + removeSpatialDataRootViv, + toSerializableSpatialDataViewState, +} from "@/react/spatialdata/spatialdata_config"; + +function imageStackWithChannels(): RenderStack { + return { + schemaVersion: RENDER_STACK_SCHEMA_VERSION, + entries: [ + { + kind: "spatial", + id: "spatialdata-image-imageA", + visible: true, + source: { elementType: "image", elementKey: "imageA" }, + props: { + opacity: 1, + channels: { + channelsVisible: [true, false], + colors: [[255, 0, 0], [0, 255, 0]], + }, + }, + }, + ], + }; +} + +describe("SpatialData chart config ownership", () => { + test("uses root viewState and layer channels without root viv state", () => { + const renderStack = imageStackWithChannels(); + const config = { + viewState: toSerializableSpatialDataViewState({ + target: [1, 2, 0], + zoom: 3, + }), + renderStack, + viv: { + channelsStore: { channelsVisible: [false] }, + viewerStore: { viewState: { target: [999, 999, 0], zoom: -1 } }, + }, + }; + + const serialized = removeSpatialDataRootViv(config); + + expect("viv" in serialized).toBe(false); + expect(serialized.viewState).toEqual({ target: [1, 2, 0], zoom: 3 }); + expect(serialized.renderStack.entries[0]?.props.channels).toEqual({ + channelsVisible: [true, false], + colors: [[255, 0, 0], [0, 255, 0]], + }); + }); +}); From d51359d814f795c24edfb95d602d415c8cc55518 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 23 Jun 2026 15:16:16 +0100 Subject: [PATCH 11/47] partially working bridge for colour channel to use our ui --- docs/spatialdata-vis-integration.md | 27 ++ .../components/ColorChannelComponents.tsx | 98 +++-- .../SpatialLayerDialogComponent.tsx | 2 - .../spatialLayers/ImageLayerPanel.tsx | 397 +++++++----------- 4 files changed, 241 insertions(+), 283 deletions(-) diff --git a/docs/spatialdata-vis-integration.md b/docs/spatialdata-vis-integration.md index add35ea41..40e857981 100644 --- a/docs/spatialdata-vis-integration.md +++ b/docs/spatialdata-vis-integration.md @@ -52,6 +52,33 @@ Remove the unused `refreshRenderStackShell` helper. Replacing only the stack she Possible follow-up: consider publishing MDV to npm so SpatialData.js can exercise real MDV integration points without local worktree/link setup. This is an integration enabler, not part of the adapter refactor. +## Avivatorish comparison + +MDV currently carries a local `src/react/components/avivatorish` implementation and also depends on `@spatialdata/avivatorish`. Treat the local copy as the integration shim for now, not as a desired long-term fork. + +The two implementations are close enough to share vocabulary: `VivProvider`, `createVivStores`, `channelsStore`, `viewerStore`, `imageSettingsStore`, `useImage`, `useLoader`, `useMetadata`, and channel selection/state helpers. Both use Zustand-style stores under React providers, and both model channel state as parallel arrays (`colors`, `contrastLimits`, `domains`, `selections`, `channelsVisible`, etc.). + +The important differences for MDV: + +- MDV's local version is already wired into MDV chart/view concerns: MobX chart config, chart-link view state, OME-TIFF upload/viewer paths, and the existing Viv scatter/image chart controls. +- MDV's local channel stats path stores sampled `raster` data in channel state. The old channel histogram UI uses that raster data to draw histograms and brush contrast ranges. +- `@spatialdata/avivatorish` exposes channel stats helpers, but the current declared stats contract returns `domains` and `contrastLimits`, not the sampled raster data needed by MDV's histogram component. +- SpatialData.js `SpatialCanvas` image loading exposes loaded image defaults (`colors`, `contrastLimits`, `channelsVisible`, `selections`) for layer panels, but not a public histogram/raster-stat surface for channel-control UI. +- SpatialData.js image rendering is OME-Zarr oriented. Its image renderer notes that SpatialData image support uses `loadOmeZarr`; MDV still has OME-TIFF paths that are outside that renderer's current scope. +- MDV's local version has awkward Zustand/MobX mixing because Viv state is runtime UI state while `chart.config.renderStack` is serialized MobX state. This is tolerable as a bridge, but it should not leak Viv root config back into SpatialData chart config. + +For the current SpatialData chart, image channel controls may reuse the old MDV channel components only for state that can be faithfully backed by `renderStack.entries[].props.channels`. Until SpatialData.js exposes real channel histogram/raster samples, MDV may render the existing histogram brush over empty raster data so users can still edit `contrastLimits`; replace that placeholder with a SpatialData.js-provided stats API when one exists. + +Histogram brush controls are sensitive to state ownership. Keep the brush value controlled by one state source only: the channel store value that will be rendered (`contrastLimits` for image channels, or the equivalent legend range elsewhere). Avoid adding a local/debounced mirror that also writes through MobX or another persistence layer, because d3 brush movement, React re-render, and persistence rehydration can otherwise chase each other and cause drag jumps. If a bridge persists brush changes into another model, skip self-echo rehydration when the incoming persisted value matches the value just written. + +Useful SpatialData.js changes before MDV can consider replacing the local `avivatorish` copy: + +- Expose a public channel-control adapter or hook that takes a layer-local image channel config and returns editable channel state plus a persistence callback, without requiring root Viv chart config. +- Expose image channel histogram/stat data, or a lazy per-selection stats API, alongside loaded image defaults. +- Make the supported image source boundary explicit: either support OME-TIFF in the shared package, or keep OME-TIFF intentionally MDV-local and separate from SpatialData image layers. +- Clarify whether the shared package supports Viv/deck.gl extensions needed by MDV (`VivContrastExtension`, color palette/colormap extensions, 2D/3D behavior), and expose extension injection where host apps need it. +- Keep the channel ownership model layer-local: image channel settings belong to image layer configs/render-stack entries, not to the SpatialData chart root. + ## Initial PR scope | Commit stage | Status | diff --git a/src/react/components/ColorChannelComponents.tsx b/src/react/components/ColorChannelComponents.tsx index bd9371ffa..47cebe708 100644 --- a/src/react/components/ColorChannelComponents.tsx +++ b/src/react/components/ColorChannelComponents.tsx @@ -17,7 +17,6 @@ import { } from "@mui/material"; import * as d3 from "d3"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; -import { useDebounce } from "use-debounce"; import { shallow } from "zustand/shallow"; import { useTheme } from "../hooks"; import { PopoverPicker } from "./ColorPicker"; @@ -30,8 +29,10 @@ import { useLoader, useMetadata, useViewerStore, + useViewerStoreApi, } from "./avivatorish/state"; import { getSingleSelectionStats } from "./avivatorish/utils"; +import { COLOR_PALLETE } from "./avivatorish/constants"; import { MAX_CHANNELS } from "@vivjs/constants"; const DEFAULT_BRIGHTNESS_CONTRAST = 0.5; @@ -100,7 +101,7 @@ const ChannelChooserMUI = ({ index }: { index: number }) => { }; const ChannelChooser = ({ index }: { index: number }) => { - const channels = useMetadata()?.Pixels.Channels.map((c) => c.Name) as string[]; + const metadata = useMetadata(); const { selections, setPropertiesForChannel } = useChannelsStore( ({ selections, setPropertiesForChannel }) => ({ selections, @@ -109,15 +110,20 @@ const ChannelChooser = ({ index }: { index: number }) => { shallow, ); const loader = useLoader(); - const { setIsChannelLoading, isChannelLoading, removeIsChannelLoading, use3d } = useViewerStore( - ({ setIsChannelLoading, isChannelLoading, removeIsChannelLoading, use3d }) => ({ + const { setIsChannelLoading, isChannelLoading, removeIsChannelLoading, use3d, channelOptions } = useViewerStore( + ({ setIsChannelLoading, isChannelLoading, removeIsChannelLoading, use3d, channelOptions }) => ({ setIsChannelLoading, isChannelLoading, removeIsChannelLoading, use3d, + channelOptions, }), shallow, ); + const channels = + metadata?.Pixels.Channels.map((c, i) => c.Name ?? `Channel ${i + 1}`) ?? + channelOptions ?? + selections.map((_, i) => `Channel ${i + 1}`); return ( <> @@ -132,6 +138,11 @@ const ChannelChooser = ({ index }: { index: number }) => { ...selections[index], c: Number.parseInt(e.target.value), }; + const data = Array.isArray(loader) ? loader[loader.length - 1] : loader; + if (!data?.getRaster) { + setPropertiesForChannel(index, { selections: selection }); + return; + } setIsChannelLoading(index, true); const { domain: domains, @@ -218,33 +229,16 @@ const ChannelHistogram = ({ index }: { index: number }) => { const channelsStore = useChannelsStoreApi(); const dark = useTheme() === "dark"; const limits = contrastLimits[index] ?? domain; - const [liveValue, setLiveValue] = useState(limits); + const domainRef = useRef(domain); + const limitsRef = useRef(limits); const [xScaleMode, setXScaleMode] = useState("auto"); const [yScaleMode, setYScaleMode] = useState("auto"); useEffect(() => { - setLiveValue(rangesEqual(limits, domain) ? null : limits); + domainRef.current = domain; + limitsRef.current = limits; }, [domain, limits]); - const [debouncedValue] = useDebounce(liveValue, 10, { - equalityFn: (a, b) => { - if (!a && !b) return true; - if (!a || !b) return false; - return a[0] === b[0] && a[1] === b[1]; - }, - }); - - useEffect(() => { - const nextValue = debouncedValue ?? domain; - if (nextValue.some((value) => Number.isNaN(value))) return; - if (rangesEqual(nextValue, limits)) { - return; - } - const nextContrastLimits = [...channelsStore.getState().contrastLimits]; - nextContrastLimits[index] = nextValue; - channelsStore.setState({ contrastLimits: nextContrastLimits }); - }, [channelsStore, debouncedValue, domain, index, limits]); - const resolvedXScale = useMemo( () => (xScaleMode === "auto" ? resolveAutoHistogramXScaleFromValues(domain, rasterData) : xScaleMode), [domain, rasterData, xScaleMode], @@ -273,23 +267,24 @@ const ChannelHistogram = ({ index }: { index: number }) => { const handleBrushValue = useCallback( (value: Range | null) => { - if (!value) { - setLiveValue(null); - return; - } - const nextValue = sortRange(clampRange(value, domain)); - setLiveValue(rangesEqual(nextValue, domain) ? null : nextValue); + const currentDomain = domainRef.current; + const currentLimits = limitsRef.current; + const nextValue = value ? sortRange(clampRange(value, currentDomain)) : currentDomain; + if (nextValue.some((item) => Number.isNaN(item)) || rangesEqual(nextValue, currentLimits)) return; + const nextContrastLimits = [...channelsStore.getState().contrastLimits]; + nextContrastLimits[index] = nextValue; + channelsStore.setState({ contrastLimits: nextContrastLimits }); }, - [domain], + [channelsStore, index], ); const brush = useMemo( () => ({ - value: liveValue, + value: rangesEqual(limits, domain) ? null : limits, setValue: handleBrushValue, minMax: domain, }), - [domain, handleBrushValue, liveValue], + [domain, handleBrushValue, limits], ); return ( @@ -393,11 +388,9 @@ const ChannelController = ({ index }: { index: number }) => { const channelVisible = useChannelsStore((state) => state.channelsVisible[index]); const removeChannel = useChannelsStore((state) => state.removeChannel); const isChannelLoading = useViewerStore((state) => state.isChannelLoading); - const metadata = useMetadata(); const channelsStore = useChannelsStoreApi(); const [isHovered, setIsHovered] = useState(false); - if (!metadata) throw "no metadata"; //TODO type metadata if (!color) return null; const hasPendingLoads = isChannelLoading.some(Boolean); @@ -536,10 +529,15 @@ const AddChannel = () => { const [isAddingChannel, setIsAddingChannel] = useState(false); // const { labels } = loader[0]; // const channelsStore = useChannelsStoreApi(); - const { selections, setPropertiesForChannel } = useChannelsStore(({ selections, setPropertiesForChannel }) => ({ - selections, - setPropertiesForChannel, - })); + const { selections, domains: channelDomains, contrastLimits: channelContrastLimits, setPropertiesForChannel } = useChannelsStore( + ({ selections, domains, contrastLimits, setPropertiesForChannel }) => ({ + selections, + domains, + contrastLimits, + setPropertiesForChannel, + }), + shallow, + ); const canAddChannel = selections.length < MAX_CHANNELS; const { addChannel, removeChannel } = useChannelsStore((state) => ({ addChannel: state.addChannel, @@ -553,6 +551,8 @@ const AddChannel = () => { }), shallow, ); + const viewerStore = useViewerStoreApi(); + const canLoadStats = Boolean((Array.isArray(loader) ? loader[loader.length - 1] : loader)?.getRaster); return (