From db58b4fe91e9a908261fce001f21e32e62812b75 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 14 Nov 2025 16:28:51 +0000 Subject: [PATCH 1/6] add usehooks dependency to vis --- packages/vis/package.json | 1 + pnpm-lock.yaml | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/vis/package.json b/packages/vis/package.json index b6c792d3..f504ebbc 100644 --- a/packages/vis/package.json +++ b/packages/vis/package.json @@ -33,6 +33,7 @@ "@math.gl/core": "catalog:", "@spatialdata/core": "workspace:*", "@spatialdata/react": "workspace:*", + "@uidotdev/usehooks": "^2.4.1", "@uiw/react-json-view": "2.0.0-alpha.39", "@vivjs-experimental/viv": "catalog:", "deck.gl": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a350fe9..2f201d98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: '@spatialdata/react': specifier: workspace:* version: link:../react + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@uiw/react-json-view': specifier: 2.0.0-alpha.39 version: 2.0.0-alpha.39(@babel/runtime@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -2756,6 +2759,13 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + '@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: @@ -10919,6 +10929,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@uidotdev/usehooks@2.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@uiw/react-json-view@2.0.0-alpha.39(@babel/runtime@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 From 046936101e955df5e411d1e326b299bd0cee4d3f Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 14 Nov 2025 16:30:18 +0000 Subject: [PATCH 2/6] fix sizing of ImageView component, still pending viewState --- packages/vis/src/ImageView/index.tsx | 35 +++++++++++++++++++--------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/vis/src/ImageView/index.tsx b/packages/vis/src/ImageView/index.tsx index ce03deeb..ef598f56 100644 --- a/packages/vis/src/ImageView/index.tsx +++ b/packages/vis/src/ImageView/index.tsx @@ -1,13 +1,13 @@ import { useSpatialData } from "@spatialdata/react"; -import { useEffect, useMemo, useState, useId, useRef } from "react"; +import { useEffect, useMemo, useState, useId, type CSSProperties } from "react"; +import { useMeasure } from "@uidotdev/usehooks"; import { createVivStores, useChannelsStore, useLoader, useViewerStore, useViewerStoreApi, VivProvider } from "./avivatorish/state"; import { DetailView, VivViewer } from "@vivjs-experimental/viv"; import { useImage } from "./avivatorish/hooks"; -function VivImage({url}: {url?: string | URL}) { - //TODO: fix sizing... seems like this should be simpler than it is. - const width = 200; - const height = 200; +function VivImage({url, width, height}: {url?: string | URL, width: number, height: number}) { + //TODO: fix viewState... seems like this should be simpler than it is. + const loader = useLoader(); const channels = useChannelsStore(({colors, contrastLimits, channelsVisible, selections}) => ({colors, contrastLimits, channelsVisible, selections})); const layerConfig = useMemo(() => ({loader, ...channels}), [loader, channels]); @@ -22,7 +22,7 @@ function VivImage({url}: {url?: string | URL}) { width, height, }) - }, [detailId]); + }, [detailId, width, height]); const deckProps = useMemo(() => ({ style: { position: 'relative', @@ -38,7 +38,8 @@ function VivImage({url}: {url?: string | URL}) { const source = useViewerStore((state) => state.source); useImage(source); if (isViewerLoading) return
Loading...
; - return (); } +const containerStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 10, + height: '40vh', + border: '1px solid gray', + borderRadius: 10, + padding: 10, +}; export default function ImageView() { const { spatialData } = useSpatialData(); const [selectedImage, setSelectedImage] = useState(''); + const [ref, { width, height }] = useMeasure(); + console.log('width', width, 'height', height); + const vivStores = useMemo(() => { return createVivStores(); }, []); @@ -65,16 +78,16 @@ export default function ImageView() { } }, [image]); return ( -
- {spatialData?.images && +
+ {spatialData?.images && ( - } + )} - +
From b58c0c8ddea210723049648e257fe7c2db60f904 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 14 Nov 2025 16:57:26 +0000 Subject: [PATCH 3/6] something approximating sane default initial viewState --- packages/vis/src/ImageView/index.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/vis/src/ImageView/index.tsx b/packages/vis/src/ImageView/index.tsx index ef598f56..11e0a0f3 100644 --- a/packages/vis/src/ImageView/index.tsx +++ b/packages/vis/src/ImageView/index.tsx @@ -2,13 +2,21 @@ import { useSpatialData } from "@spatialdata/react"; import { useEffect, useMemo, useState, useId, type CSSProperties } from "react"; import { useMeasure } from "@uidotdev/usehooks"; import { createVivStores, useChannelsStore, useLoader, useViewerStore, useViewerStoreApi, VivProvider } from "./avivatorish/state"; -import { DetailView, VivViewer } from "@vivjs-experimental/viv"; +import { DetailView, VivViewer, getDefaultInitialViewState } from "@vivjs-experimental/viv"; import { useImage } from "./avivatorish/hooks"; +function _isValidImage(image: ReturnType) { + if (!image) return false; + // when trying to getDefaultInitialViewState, it'll do something a bit like this internally... + // the conditions under which this function returns false are conditions where internally it would have pixelWidth undefined, etc. + const source = Array.isArray(image) ? image[0] : image; + return source.shape.length > 0; +} + function VivImage({url, width, height}: {url?: string | URL, width: number, height: number}) { //TODO: fix viewState... seems like this should be simpler than it is. - const loader = useLoader(); + const loader = useLoader(); //could do with typing this... const channels = useChannelsStore(({colors, contrastLimits, channelsVisible, selections}) => ({colors, contrastLimits, channelsVisible, selections})); const layerConfig = useMemo(() => ({loader, ...channels}), [loader, channels]); const id = useId(); @@ -35,6 +43,16 @@ function VivImage({url, width, height}: {url?: string | URL, width: number, heig console.log('setting source', source); viewerStore.setState({ source }); }, [url, viewerStore]); + useEffect(() => { + if (!_isValidImage(loader)) return; + if (width === 0 || height === 0) return; + if (!viewState) { + const zoomBackOff = 0.2; + const viewState = getDefaultInitialViewState(loader, {width, height}, zoomBackOff); + console.log('setting viewState', viewState); + viewerStore.setState({ viewState }); + } + }, [loader, viewState, viewerStore, viewerStore.setState, width, height]); const source = useViewerStore((state) => state.source); useImage(source); if (isViewerLoading) return
Loading...
; @@ -61,7 +79,6 @@ export default function ImageView() { const { spatialData } = useSpatialData(); const [selectedImage, setSelectedImage] = useState(''); const [ref, { width, height }] = useMeasure(); - console.log('width', width, 'height', height); const vivStores = useMemo(() => { return createVivStores(); From 4a3ed3e0441958b62899ed8e58879ecdc2c8c9a5 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 14 Nov 2025 17:21:19 +0000 Subject: [PATCH 4/6] glitchy select first available image, unset viewState when changing image --- packages/vis/src/ImageView/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/vis/src/ImageView/index.tsx b/packages/vis/src/ImageView/index.tsx index 11e0a0f3..3723bcde 100644 --- a/packages/vis/src/ImageView/index.tsx +++ b/packages/vis/src/ImageView/index.tsx @@ -41,7 +41,7 @@ function VivImage({url, width, height}: {url?: string | URL, width: number, heig if (!url) return; const source = { urlOrFile: url.toString(), description: 'image' }; console.log('setting source', source); - viewerStore.setState({ source }); + viewerStore.setState({ source, viewState: null }); }, [url, viewerStore]); useEffect(() => { if (!_isValidImage(loader)) return; @@ -79,6 +79,13 @@ export default function ImageView() { const { spatialData } = useSpatialData(); const [selectedImage, setSelectedImage] = useState(''); const [ref, { width, height }] = useMeasure(); + + useEffect(() => { + if (!spatialData?.images) return; + if (selectedImage === '' || !spatialData.images[selectedImage]) { + setSelectedImage(Object.keys(spatialData.images)[0]); + } + }, [spatialData?.images, selectedImage]); const vivStores = useMemo(() => { return createVivStores(); From 5c54b409606b8cfdc7f340d6b141be628ad5b680 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Fri, 5 Dec 2025 20:40:17 +0000 Subject: [PATCH 5/6] better handling of viewState reset when loader changes --- packages/vis/src/ImageView/index.tsx | 64 ++++++++++++++++++---------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/vis/src/ImageView/index.tsx b/packages/vis/src/ImageView/index.tsx index 3723bcde..b82975c4 100644 --- a/packages/vis/src/ImageView/index.tsx +++ b/packages/vis/src/ImageView/index.tsx @@ -1,7 +1,7 @@ import { useSpatialData } from "@spatialdata/react"; -import { useEffect, useMemo, useState, useId, type CSSProperties } from "react"; +import { useEffect, useMemo, useState, useId, type CSSProperties, useCallback } from "react"; import { useMeasure } from "@uidotdev/usehooks"; -import { createVivStores, useChannelsStore, useLoader, useViewerStore, useViewerStoreApi, VivProvider } from "./avivatorish/state"; +import { createVivStores, useChannelsStore, useLoader, useViewerStore, useViewerStoreApi, VivProvider, useChannelsStoreApi, DEFAUlT_CHANNEL_STATE } from "./avivatorish/state"; import { DetailView, VivViewer, getDefaultInitialViewState } from "@vivjs-experimental/viv"; import { useImage } from "./avivatorish/hooks"; @@ -13,8 +13,8 @@ function _isValidImage(image: ReturnType) { return source.shape.length > 0; } -function VivImage({url, width, height}: {url?: string | URL, width: number, height: number}) { - //TODO: fix viewState... seems like this should be simpler than it is. +function VivImage({ url, width, height }: { url?: string | URL; width: number; height: number }) { + //TODO: fix viewState... seems like this should be simpler than it is. const loader = useLoader(); //could do with typing this... const channels = useChannelsStore(({colors, contrastLimits, channelsVisible, selections}) => ({colors, contrastLimits, channelsVisible, selections})); @@ -37,31 +37,51 @@ function VivImage({url, width, height}: {url?: string | URL, width: number, heig } }), []); const viewerStore = useViewerStoreApi(); - useEffect(() => { - if (!url) return; - const source = { urlOrFile: url.toString(), description: 'image' }; - console.log('setting source', source); - viewerStore.setState({ source, viewState: null }); - }, [url, viewerStore]); - useEffect(() => { - if (!_isValidImage(loader)) return; - if (width === 0 || height === 0) return; - if (!viewState) { - const zoomBackOff = 0.2; - const viewState = getDefaultInitialViewState(loader, {width, height}, zoomBackOff); - console.log('setting viewState', viewState); - viewerStore.setState({ viewState }); - } - }, [loader, viewState, viewerStore, viewerStore.setState, width, height]); + const channelsStore = useChannelsStoreApi(); + + const resetViewState = useCallback(() => { + if (!_isValidImage(loader) || width === 0 || height === 0) return; + const zoomBackOff = 0.2; + const newViewState = getDefaultInitialViewState(loader, { width, height }, zoomBackOff); + console.log("resetting viewState", newViewState); + viewerStore.setState({ viewState: newViewState }); + }, [loader, width, height, viewerStore]); + + useEffect(() => { + if (!url) return; + const source = { urlOrFile: url.toString(), description: "image" }; + viewerStore.setState({ source, viewState: null }); + channelsStore.setState({ loader: DEFAUlT_CHANNEL_STATE.loader }); + }, [url, viewerStore, channelsStore]); + + useEffect(() => { + if (viewState === null && _isValidImage(loader)) { + resetViewState(); + } + }, [viewState, resetViewState, loader]); + + // useEffect(() => { + // const listener = (e: KeyboardEvent) => { + // if (e.key === ".") { + // resetViewState(); + // } + // }; + // window.addEventListener("keydown", listener); + // return () => window.removeEventListener("keydown", listener); + // }, [resetViewState]); + const source = useViewerStore((state) => state.source); useImage(source); - if (isViewerLoading) return
Loading...
; + if (isViewerLoading || !viewState) return
Loading...
; return ( { + viewerStore.setState({ viewState: newViewState }); + }} />); } From ab0241be40149a80e9a710e221b124571feab854 Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Mon, 8 Dec 2025 09:21:22 +0000 Subject: [PATCH 6/6] some comments --- packages/vis/src/ImageView/index.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/vis/src/ImageView/index.tsx b/packages/vis/src/ImageView/index.tsx index b82975c4..355a494e 100644 --- a/packages/vis/src/ImageView/index.tsx +++ b/packages/vis/src/ImageView/index.tsx @@ -5,17 +5,17 @@ import { createVivStores, useChannelsStore, useLoader, useViewerStore, useViewer import { DetailView, VivViewer, getDefaultInitialViewState } from "@vivjs-experimental/viv"; import { useImage } from "./avivatorish/hooks"; +/** + * when trying to getDefaultInitialViewState, it'll do something a bit like this internally... + * the conditions under which this function returns false are conditions where internally it would have pixelWidth undefined, etc. + */ function _isValidImage(image: ReturnType) { if (!image) return false; - // when trying to getDefaultInitialViewState, it'll do something a bit like this internally... - // the conditions under which this function returns false are conditions where internally it would have pixelWidth undefined, etc. const source = Array.isArray(image) ? image[0] : image; return source.shape.length > 0; } function VivImage({ url, width, height }: { url?: string | URL; width: number; height: number }) { - //TODO: fix viewState... seems like this should be simpler than it is. - const loader = useLoader(); //could do with typing this... const channels = useChannelsStore(({colors, contrastLimits, channelsVisible, selections}) => ({colors, contrastLimits, channelsVisible, selections})); const layerConfig = useMemo(() => ({loader, ...channels}), [loader, channels]); @@ -85,6 +85,7 @@ function VivImage({ url, width, height }: { url?: string | URL; width: number; h />); } +// todo better styling to customise, easily fit into application layout etc. const containerStyle: CSSProperties = { display: 'flex', flexDirection: 'column', @@ -95,7 +96,20 @@ const containerStyle: CSSProperties = { padding: 10, }; +/** + * This component can be used within a `SpatialDataContext` and provides a UI for selecting + * images from the loaded object to be viewed with Viv. + * + * It internally manages 'Avivator-ish' zustand state, with a React context that is independent + * of any other instances (which also entails not being able to share image data between components). + * + * As of writing, it doesn't expose any public API for interacting with these stores, customisation etc. + * That should be changed - but the API will probably not be stable, particularly initially. + */ export default function ImageView() { + // todo decide what public API should like etc + // - particularly with more complex spatial layers arrangement + // enough tools to do useful things const { spatialData } = useSpatialData(); const [selectedImage, setSelectedImage] = useState(''); const [ref, { width, height }] = useMeasure();