diff --git a/anndata-zarr/.eslintrc.json b/anndata-zarr/.eslintrc.json new file mode 100644 index 00000000..69304d74 --- /dev/null +++ b/anndata-zarr/.eslintrc.json @@ -0,0 +1,43 @@ +{ + "extends": ["react-app", "prettier", "plugin:import/errors", "plugin:import/warnings", "plugin:prettier/recommended"], + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + }, + "alias": { + "map": [["@app", "./src"]], + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + }, + "rules": { + "import/order": [ + "error", + { + "groups": ["builtin", "external", "internal", ["parent", "sibling"], "index"], + "pathGroups": [ + { + "pattern": "react", + "group": "external", + "position": "before" + } + ], + "pathGroupsExcludedImportTypes": ["react"], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ], + "prettier/prettier": [ + "error", + { + "singleQuote": true, + "tabWidth": 2, + "useTabs": false + } + ] + } +} diff --git a/anndata-zarr/.gitignore b/anndata-zarr/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/anndata-zarr/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/anndata-zarr/package.json b/anndata-zarr/package.json new file mode 100644 index 00000000..bdd7650b --- /dev/null +++ b/anndata-zarr/package.json @@ -0,0 +1,43 @@ +{ + "name": "@biongff/anndata-zarr", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "dist/biongff-anndata-zarr.cjs.js", + "module": "dist/biongff-anndata-zarr.es.js", + "files": ["dist"], + "exports": { + ".": { + "import": "./dist/biongff-anndata-zarr.es.js", + "require": "./dist/biongff-anndata-zarr.cjs.js" + }, + "./dist/anndata-zarr.css": "./dist/anndata-zarr.css" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "@tanstack/react-query": "^5.85.3", + "lodash": "^4.17.21", + "react-window": "^2.0.2", + "zarrita": "0.5.0" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@mui/icons-material": "^7.2.0", + "@mui/material": "^7.2.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "vite": "^6.2.3", + "vitest": "^3.0.8" + } +} diff --git a/anndata-zarr/src/components/AnndataController.jsx b/anndata-zarr/src/components/AnndataController.jsx new file mode 100644 index 00000000..5ea26f92 --- /dev/null +++ b/anndata-zarr/src/components/AnndataController.jsx @@ -0,0 +1,33 @@ +import React, { useState } from "react"; + +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; + +import { FeatureSelect } from "./FeatureSelect"; +import { ObsSelect } from "./ObsSelect"; + +export const AnndataController = ({ adata, callback = () => {} }) => { + const [feature, setFeature] = useState(null); + const [obsCol, setObsCol] = useState(null); + + const handleFeatureSelect = (f) => { + setFeature(f); + setObsCol(null); + }; + + const handleObsSelect = (o) => { + setObsCol(o); + setFeature(null); + }; + + return ( + + + + + + + + + ); +}; diff --git a/anndata-zarr/src/components/FeatureSelect.jsx b/anndata-zarr/src/components/FeatureSelect.jsx new file mode 100644 index 00000000..6614f23e --- /dev/null +++ b/anndata-zarr/src/components/FeatureSelect.jsx @@ -0,0 +1,108 @@ +import React, { useEffect, useMemo, useState } from "react"; + +import Box from "@mui/material/Box"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import { List } from "react-window"; + +import { useAnndataColors, useAnndataFeatures } from "../hooks"; +import { Legend } from "./Legend"; + +const RowComponent = ({ index, items, style, onSelect, selectedIndex }) => { + return ( + + onSelect({ index: items[index].matrixIndex })} + selected={items[index].matrixIndex === selectedIndex} + > + + + + ); +}; + +export const FeatureSelect = ({ adata, feature, onSelect, callback = () => {} }) => { + const [searchTerm, setSearchTerm] = useState(""); + + const { data, isLoading, serverError } = useAnndataFeatures(adata); + const colorData = useAnndataColors( + { + ...adata, + matrixProps: { + feature: feature, + }, + }, + { enabled: !!feature }, + ); + + useEffect(() => { + if (colorData?.serverError) { + callback(null); + return; + } + if (!colorData?.isLoading && colorData?.data) { + callback(colorData.data.colors); + } + }, [colorData, callback]); + + const items = useMemo(() => { + if (!data) return []; + const allItems = data.map((name, index) => ({ + name, + matrixIndex: index, + })); + if (!searchTerm) return allItems; + return allItems.filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase())); + }, [data, searchTerm]); + + const legend = useMemo(() => { + if (colorData?.serverError || colorData?.isLoading || !colorData?.data) { + return null; + } + const { min, max, colorscale } = colorData.data; + return ; + }, [colorData.data, colorData?.isLoading, colorData?.serverError]); + + if (isLoading) { + return <>; + } + if (serverError) { + return
Error loading features
; + } + return ( + + + setSearchTerm(e.target.value)} + /> + + {!!feature && legend} + + + ); +}; diff --git a/anndata-zarr/src/components/Legend.jsx b/anndata-zarr/src/components/Legend.jsx new file mode 100644 index 00000000..0320e54a --- /dev/null +++ b/anndata-zarr/src/components/Legend.jsx @@ -0,0 +1,25 @@ +import React, { useMemo } from "react"; + +import _ from "lodash"; + +import { getColor } from "../utils"; + +export const Legend = ({ min, max, colorscale }) => { + const spanList = useMemo(() => { + return _.range(100).map((i) => { + const color = getColor({ value: i / 100, colorscale }); + return ; + }); + }, [colorscale]); + + return ( +
+
+ {spanList} + {min.toFixed(2)} + {((min + max) / 2).toFixed(2)} + {max.toFixed(2)} +
+
+ ); +}; diff --git a/anndata-zarr/src/components/ObsSelect.jsx b/anndata-zarr/src/components/ObsSelect.jsx new file mode 100644 index 00000000..747f3862 --- /dev/null +++ b/anndata-zarr/src/components/ObsSelect.jsx @@ -0,0 +1,143 @@ +import React, { useEffect, useMemo, useState } from "react"; + +import ExpandLess from "@mui/icons-material/ExpandLess"; +import ExpandMore from "@mui/icons-material/ExpandMore"; +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Collapse from "@mui/material/Collapse"; +import Divider from "@mui/material/Divider"; +import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import Stack from "@mui/material/Stack"; + +import { COLORSCALES } from "../constants/colorscales"; +import { useAnndataColors, useAnndataObs } from "../hooks"; +import { getColor } from "../utils"; +import { Legend } from "./Legend"; + +// @TODO: fix styling (width) +const CategoricalCol = ({ col, showColor = false }) => { + const [open, setOpen] = useState(false); + const { categories } = col; + + return ( + + setOpen(!open)} sx={{ display: "flex", alignItems: "center", cursor: "pointer" }}> + e.stopPropagation()} />} + label={col.name} + key={col.name} + value={col.name} + /> + {open ? : } + + + {categories.length > 100 && ( + + Truncated to 100 categories + + )} + + {categories.slice(0, 100).map((cat, i) => ( + + {showColor && ( + + + + )} + + + ))} + + + + ); +}; + +const NumericalCol = ({ col }) => { + return } label={col.name} key={col.name} value={col.name} />; +}; + +export const ObsSelect = ({ adata, obsCol, onSelect, callback = () => {} }) => { + const { data, isLoading, serverError } = useAnndataObs(adata); + const colorData = useAnndataColors( + { + ...adata, + matrixProps: { + obs: { col: obsCol }, + }, + }, + { + enabled: !!obsCol, + }, + ); + + useEffect(() => { + if (colorData?.serverError) { + callback(null); + return; + } + if (!colorData?.isLoading && colorData?.data) { + callback(colorData.data.colors); + } + }, [colorData, callback]); + + const legend = useMemo(() => { + if (colorData?.serverError || colorData?.isLoading || !colorData?.data) { + return null; + } + const { min, max, colorscale, categories } = colorData.data; + if (categories) { + return null; + } + return ; + }, [colorData.data, colorData?.isLoading, colorData?.serverError]); + + if (isLoading) { + return <>; + } + if (serverError) { + return
Error loading obs
; + } + return ( + + + Observations + + + onSelect(e.target.value)}> + Categorical + {data.categorical.map((col) => ( + + ))} + Numerical + {data.numerical.map((col) => ( + + ))} + + + + {!!obsCol && legend} + + + ); +}; diff --git a/anndata-zarr/src/constants/colorscales.js b/anndata-zarr/src/constants/colorscales.js new file mode 100644 index 00000000..4d7ec392 --- /dev/null +++ b/anndata-zarr/src/constants/colorscales.js @@ -0,0 +1,89 @@ +// From plotly https://github.com/plotly/plotly.js/blob/5bc25b490702e5ed61265207833dbd58e8ab27f1/src/components/colorscale/scales.js +export const COLORSCALES = { + Greys: ["#000000", "#ffffff"], + + YlGnBu: ["#081d58", "#253494", "#225ea8", "#1d91c0", "#41b6c4", "#7fcdbb", "#c7e9b4", "#edf8d9", "#ffffd9"], + + Greens: ["#00441b", "#006d2c", "#238b45", "#41ab5d", "#74c476", "#a1d9a5", "#c7e9c0", "#e5f5e0", "#f7fcf5"], + + YlOrRd: ["#800026", "#bd0026", "#e31a1c", "#fc4e2a", "#fd8d3c", "#feb24c", "#fed976", "#ffed9f", "#ffffcc"], + + Bluered: ["#0000ff", "#ff0000"], + + RdBu: ["#050aac", "#6a89f7", "#bebebe", "#dcaa84", "#e6915a", "#b20a1c"], + + Reds: ["#dcdcdc", "#f5c39d", "#f5a069", "#b20a1c"], + + Blues: ["#050aac", "#283cba", "#4664f5", "#5a78f5", "#6a89f7", "#dcdcdc"], + + Picnic: [ + "#0000ff", + "#3399ff", + "#66ccff", + "#99ccff", + "#ccccff", + "#ffffff", + "#ffccff", + "#ff99ff", + "#ff66cc", + "#ff6666", + "#ff0000", + ], + + Rainbow: ["#96005a", "#0000c8", "#0019ff", "#0098ff", "#2cff96", "#97ff00", "#ffe600", "#ff6f00", "#ff0000"], + + Portland: ["#0c3383", "#0a88ba", "#f2d338", "#f28f38", "#d91e1e"], + + Jet: ["#000083", "#003caa", "#05ffff", "#ffff00", "#fa0000", "#800000"], + + Hot: ["#000000", "#e60000", "#ffd200", "#ffffff"], + + Blackbody: ["#000000", "#e60000", "#e6d200", "#ffffff", "#a0c8ff"], + + Earth: ["#000082", "#00b4b4", "#28d228", "#e6e632", "#784614", "#ffffff"], + + Electric: ["#000000", "#1e0064", "#780064", "#a05a00", "#e6c800", "#fffadc"], + + Viridis: [ + "#440154", + "#48186a", + "#472d7b", + "#424086", + "#3b528b", + "#33638d", + "#2c728e", + "#26828e", + "#21918c", + "#1fa088", + "#28ae80", + "#3fbc73", + "#5ec962", + "#84d44b", + "#addc30", + "#d8e219", + "#fde725", + ], + + Cividis: [ + "#00204c", + "#002a66", + "#00346e", + "#273f6c", + "#3c4a6c", + "#4c556b", + "#5b5f6d", + "#686a70", + "#757575", + "#838178", + "#929c78", + "#a19676", + "#b0a572", + "#c0af6d", + "#d1ba65", + "#e1c75c", + "#f3db4f", + "#ffe945", + ], + + Accent: ["#7fc97f", "#beaed4", "#fdc086", "#ffff99", "#386cb0", "#f0027f", "#bf5b17", "#666666"], +}; diff --git a/anndata-zarr/src/hooks.js b/anndata-zarr/src/hooks.js new file mode 100644 index 00000000..73f9b702 --- /dev/null +++ b/anndata-zarr/src/hooks.js @@ -0,0 +1,104 @@ +import { useCallback } from "react"; + +import { useQueries, useQuery } from "@tanstack/react-query"; +import _ from "lodash"; + +import { COLORSCALES } from "./constants/colorscales"; +import { fetchDataFromZarr, getColors, getObs, getVarNames, getZarrPath } from "./utils"; + +const getAnndataColors = async (url, matrixProps, colorProps) => { + let zarrData; + try { + const zarrPath = await getZarrPath(url, matrixProps); + zarrData = await fetchDataFromZarr(zarrPath.url, zarrPath.path, zarrPath.s); + } catch (error) { + console.error(error); + return null; + } + if (!zarrData) return null; + + const { categories } = zarrData; + + const max = categories ? categories.length - 1 : colorProps?.max || _.max(zarrData.data); + const min = categories ? 0 : colorProps?.min || _.min(zarrData.data); + const colorscale = categories ? COLORSCALES.Accent : colorProps?.colorscale; + + return { + colors: getColors({ + data: zarrData.data, + max, + min, + colorProps: { ...colorProps, colorscale }, + categories, + }), + max, + min, + ...(categories ? { categories } : {}), + colorscale, + }; +}; + +export const useAnndataColors = (adata = { url: null }, opts = {}) => { + const { + data = null, + isLoading = false, + serverError = null, + } = useQuery({ + queryKey: ["anndataColor", adata.url, adata.matrixProps, adata.colorProps], + queryFn: () => getAnndataColors(adata.url, adata.matrixProps, adata.colorProps), + ...opts, + }); + + return { data, isLoading, serverError }; +}; + +export const useAnndatasColors = (adatas = [], opts = {}) => { + const combine = useCallback((results) => { + return { + data: results.map((result) => result.data), + isLoading: results.some((result) => result.isLoading), + serverError: results.find((result) => result.error), + }; + }, []); + + const { + data = null, + isLoading = false, + serverError = null, + } = useQueries({ + queries: adatas.map(({ url, matrixProps, colorProps }) => ({ + queryKey: ["anndataColor", url, matrixProps, colorProps], + queryFn: () => getAnndataColors(url, matrixProps, colorProps), + })), + ...opts, + combine, + }); + + return { data, isLoading, serverError }; +}; + +export const useAnndataFeatures = (adata = { url: null, namesCol: null }) => { + const { + data = null, + isLoading = false, + serverError = null, + } = useQuery({ + queryKey: ["anndataFeatures", adata.url, adata.namesCol], + queryFn: () => getVarNames(adata.url, adata.namesCol), + }); + + return { data, isLoading, serverError }; +}; + +export const useAnndataObs = (adata = { url: null }) => { + const { + data = null, + isLoading = false, + serverError = null, + } = useQuery({ + queryKey: ["anndataObs", adata.url], + queryFn: () => getObs(adata.url), + }); + + return { data, isLoading, serverError }; +}; diff --git a/anndata-zarr/src/index.css b/anndata-zarr/src/index.css new file mode 100644 index 00000000..ada27f24 --- /dev/null +++ b/anndata-zarr/src/index.css @@ -0,0 +1,37 @@ +.grad-step { + display: inline-block; + height: 20px; + width: 1%; +} + +.gradient { + width: 100%; + white-space: nowrap; + position: relative; + display: inline-block; + top: 4px; + padding-bottom: 15px; +} + +.gradient .domain-min { + position: absolute; + left: 0; + font-size: 11px; + bottom: 3px; +} + +.gradient .domain-mid { + position: absolute; + right: 25%; + left: 25%; + text-align: center; + font-size: 11px; + bottom: 3px; +} + +.gradient .domain-max { + position: absolute; + right: 0; + font-size: 11px; + bottom: 3px; +} diff --git a/anndata-zarr/src/index.js b/anndata-zarr/src/index.js new file mode 100644 index 00000000..3e49b763 --- /dev/null +++ b/anndata-zarr/src/index.js @@ -0,0 +1,8 @@ +import "./index.css"; + +export { useAnndataColors } from "./hooks"; +export { AnndataProvider } from "./provider"; +export { COLORSCALES } from "./constants/colorscales"; +export { FeatureSelect } from "./components/FeatureSelect"; +export { ObsSelect } from "./components/ObsSelect"; +export { AnndataController } from "./components/AnndataController"; diff --git a/anndata-zarr/src/provider.jsx b/anndata-zarr/src/provider.jsx new file mode 100644 index 00000000..c250d401 --- /dev/null +++ b/anndata-zarr/src/provider.jsx @@ -0,0 +1,9 @@ +import React from "react"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); + +export function AnndataProvider({ children }) { + return {children}; +} diff --git a/anndata-zarr/src/utils.js b/anndata-zarr/src/utils.js new file mode 100644 index 00000000..3e206c43 --- /dev/null +++ b/anndata-zarr/src/utils.js @@ -0,0 +1,170 @@ +import _ from "lodash"; +import { FetchStore, get, open } from "zarrita"; + +import { COLORSCALES } from "./constants/colorscales"; + +export const fetchDataFromZarr = async (url, path, s) => { + try { + const store = new FetchStore(url); + const node = await open(store, { kind: "group" }); + + const dataNode = await open(node.resolve(path)); + let result; + if (dataNode.attrs?.["encoding-type"] === "array" && dataNode.dtype === "bool") { + const boolData = await get(dataNode, s); + result = { + data: Array.from(boolData.data), + categories: ["false", "true"], + }; + } else if (dataNode.attrs?.["encoding-type"] === "array" || dataNode.attrs?.["encoding-type"] === "string-array") { + result = await get(dataNode, s); + } else if (dataNode.attrs?.["encoding-type"] === "categorical") { + const categoriesArr = await open(dataNode.resolve("categories"), { + kind: "array", + }); + const codesArr = await open(dataNode.resolve("codes"), { kind: "array" }); + const { data: categories } = await get(categoriesArr); + const { data } = await get(codesArr, s); + result = { data, categories }; + } else { + throw new Error("Unsupported encoding-type"); + } + + return result; + } catch (error) { + // biome-ignore lint/complexity/noUselessCatch: @TODO: better error handling + throw error; + } +}; + +export const getVarNames = async (url, namesCol = "_index") => { + try { + const store = new FetchStore(url); + const node = await open(store, { kind: "group" }); + + const arr = await open(node.resolve(`var/${namesCol}`, { kind: "array" })); + const varNames = (await get(arr)).data; + return varNames; + } catch (error) { + console.error(error); + return []; + } +}; + +export const getObs = async (url) => { + try { + const store = new FetchStore(url); + const node = await open(store, { kind: "group" }); + + const cols = (await open(node.resolve("obs", { kind: "group" }))).attrs?.["column-order"]; + const obs = { categorical: [], numerical: [] }; + for (const col of cols) { + const dataNode = await open(node.resolve(`obs/${col}`)); + const { "encoding-type": encodingType } = dataNode.attrs || {}; + if (encodingType === "categorical") { + const categoriesArr = await open(dataNode.resolve("categories"), { + kind: "array", + }); + const { data: categories } = await get(categoriesArr); + obs.categorical.push({ name: col, categories }); + } else if (encodingType === "array") { + if (dataNode.dtype === "bool") { + obs.categorical.push({ name: col, categories: ["false", "true"] }); + } else { + obs.numerical.push({ name: col }); + } + } + } + return obs; + } catch (error) { + console.error(error); + return []; + } +}; + +export const getVarIndex = async (url, varId, namesCol = "_index") => { + try { + const store = new FetchStore(url); + const node = await open(store, { kind: "group" }); + + const arr = await open(node.resolve(`var/${namesCol}`, { kind: "array" })); + const varNames = (await get(arr)).data; + const varIndex = varNames.findIndex((name) => name === varId); + return varIndex; + } catch (error) { + return -1; + } +}; + +export const getZarrPath = async (url, matrixProps) => { + const { feature, obs } = matrixProps; + if (feature) { + if (feature.index !== undefined && feature.index !== null) { + return { url, path: "X", s: [null, feature.index] }; + } + if (feature.name) { + return { + url, + path: "X", + s: [null, await getVarIndex(url, feature.name, feature.namesCol)], + }; + } + } + + if (obs) { + return { + url, + path: `obs/${obs.col}`, + s: null, + }; + } + + throw new Error("No feature or obs in matrixProps"); +}; + +const parseHexColor = (color) => { + const r = Number.parseInt(color?.substring(1, 3), 16); + const g = Number.parseInt(color?.substring(3, 5), 16); + const b = Number.parseInt(color?.substring(5, 7), 16); + + return [r, g, b]; +}; + +const interpolateColor = (color1, color2, factor) => { + const [r1, g1, b1] = parseHexColor(color1); + const [r2, g2, b2] = parseHexColor(color2); + + const r = Math.round(r1 + factor * (r2 - r1)); + const g = Math.round(g1 + factor * (g2 - g1)); + const b = Math.round(b1 + factor * (b2 - b1)); + + return [r, g, b]; +}; + +const computeColor = (colormap, value) => { + if (!colormap || Number.isNaN(value)) { + return [0, 0, 0, 255]; + } + if (value <= 0) { + return parseHexColor(colormap[0]); + } + if (value >= 1) { + return parseHexColor(colormap[colormap.length - 1]); + } + const index1 = Math.floor(value * (colormap.length - 1)); + const index2 = Math.ceil(value * (colormap.length - 1)); + const factor = (value * (colormap.length - 1)) % 1; + return interpolateColor(colormap[index1], colormap[index2], factor); +}; + +export const getColor = ({ value, colorscale = COLORSCALES.Viridis }) => { + return [...computeColor(colorscale, value), 255]; +}; + +export const getColors = ({ data, max, min, colorProps, categories }) => { + return _.map(data, (v, i) => ({ + labelValue: i + 1, + rgba: getColor({ value: (v - min) / (max - min), ...colorProps }), + value: categories ? (categories[v] ?? v) : v, + })); +}; diff --git a/anndata-zarr/vite.config.js b/anndata-zarr/vite.config.js new file mode 100644 index 00000000..deb993fe --- /dev/null +++ b/anndata-zarr/vite.config.js @@ -0,0 +1,27 @@ +import path from "node:path"; + +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + // outDir: path.resolve(__dirname, '../dist'), + lib: { + entry: path.resolve(__dirname, "src/index.js"), + name: "BiongffAnndataZarr", + formats: ["es", "cjs"], + fileName: (format) => `biongff-anndata-zarr.${format}.js`, + }, + rollupOptions: { + external: ["react", "react-dom", "@mui/material", "@mui/icons-material", "@emotion/react", "@emotion/styled"], + output: { + globals: { + react: "React", + "react-dom": "ReactDOM", + }, + }, + }, + }, +}); diff --git a/package.json b/package.json index ad8821c4..7cd66d39 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "check": "pnpm build:viewer && pnpm -r run check", "build:viewer": "pnpm --filter vizarr build", "build:app": "pnpm --filter app build", - "build": "pnpm build:viewer && pnpm build:app", + "build:anndata-zarr": "pnpm --filter anndata-zarr build", + "build": "pnpm build:viewer && pnpm build:anndata-zarr && pnpm build:app", "test": "vitest" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1119d24..cc5ddfd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,10 +65,56 @@ importers: specifier: ^4.0.15 version: 4.0.15(@types/node@24.3.0)(yaml@2.8.2) + anndata-zarr: + dependencies: + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@18.3.18)(react@18.3.1) + '@emotion/styled': + specifier: ^11.14.1 + version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1) + '@mui/icons-material': + specifier: ^7.2.0 + version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.18)(react@18.3.1) + '@mui/material': + specifier: ^7.2.0 + version: 7.3.4(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.85.3 + version: 5.90.21(react@18.3.1) + lodash: + specifier: ^4.17.21 + version: 4.17.21 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-window: + specifier: ^2.0.2 + version: 2.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zarrita: + specifier: 0.5.0 + version: 0.5.0 + devDependencies: + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.3.4(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2)) + vite: + specifier: ^6.2.3 + version: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) + vitest: + specifier: ^3.0.8 + version: 3.2.4(@types/node@24.3.0)(yaml@2.8.2) + sites/app: dependencies: + '@biongff/anndata-zarr': + specifier: workspace:anndata-zarr + version: link:../../anndata-zarr '@biongff/vizarr': - specifier: workspace:* + specifier: workspace:viewer version: link:../../viewer '@emotion/react': specifier: ^11.14.0 @@ -1310,6 +1356,14 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@turf/boolean-clockwise@5.1.5': resolution: {integrity: sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==} @@ -1477,9 +1531,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.15': resolution: {integrity: sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.15': resolution: {integrity: sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==} peerDependencies: @@ -1491,18 +1559,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.15': resolution: {integrity: sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.15': resolution: {integrity: sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.15': resolution: {integrity: sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.15': resolution: {integrity: sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.15': resolution: {integrity: sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==} @@ -1706,6 +1789,10 @@ packages: resolution: {integrity: sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==} engines: {node: '>=0.10.0'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1716,6 +1803,10 @@ packages: cartocolor@5.0.2: resolution: {integrity: sha512-Ihb/wU5V6BVbHwapd8l/zg7bnhZ4YPFVfa7quSpL86lfkPJSf4YuNBT+EvesPRP5vSqhl6vZVsQJwCR8alBooQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.1: resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} engines: {node: '>=18'} @@ -1739,6 +1830,10 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -1934,6 +2029,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deck.gl@9.0.41: resolution: {integrity: sha512-Exj/E18ZYWXdkZjz+gS+kSxll7kIpAO0dfOsI7jgMjdn6g0NWEv7W4GJIkPbGkr6E/GurAJRRC9gdjtTS/LKYA==} peerDependencies: @@ -1948,6 +2052,10 @@ packages: react-dom: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2446,6 +2554,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2550,6 +2661,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2922,6 +3036,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pbf@3.3.0: resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} hasBin: true @@ -3024,6 +3142,12 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-window@2.2.7: + resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3241,6 +3365,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} @@ -3315,6 +3442,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3323,10 +3453,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3431,6 +3573,11 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-dts@4.5.4: resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} peerDependencies: @@ -3480,6 +3627,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.15: resolution: {integrity: sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4942,6 +5117,13 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 18.3.1 + '@turf/boolean-clockwise@5.1.5': dependencies: '@turf/helpers': 5.1.5 @@ -5192,6 +5374,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.15': dependencies: '@standard-schema/spec': 1.0.0 @@ -5201,6 +5391,14 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) + '@vitest/mocker@4.0.15(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.15 @@ -5209,23 +5407,49 @@ snapshots: optionalDependencies: vite: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.15': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.15': dependencies: '@vitest/utils': 4.0.15 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.15': dependencies: '@vitest/pretty-format': 4.0.15 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.15': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.15': dependencies: '@vitest/pretty-format': 4.0.15 @@ -5472,6 +5696,8 @@ snapshots: buf-compare@1.0.1: {} + cac@6.7.14: {} + callsites@3.1.0: {} caniuse-lite@1.0.30001703: {} @@ -5480,6 +5706,14 @@ snapshots: dependencies: colorbrewer: 1.5.6 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.1: {} chalk@2.4.2: @@ -5499,6 +5733,8 @@ snapshots: charenc@0.0.2: {} + check-error@2.1.3: {} + clean-stack@2.2.0: {} clean-stack@5.3.0: @@ -5686,6 +5922,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + deck.gl@9.0.41(@arcgis/core@4.32.8(@lit/context@1.1.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@deck.gl/aggregation-layers': 9.0.41(@deck.gl/core@9.0.41)(@deck.gl/layers@9.0.41(@deck.gl/core@9.0.41)(@loaders.gl/core@4.3.3)(@luma.gl/core@9.0.28)(@luma.gl/engine@9.0.28(@luma.gl/core@9.0.28)))(@luma.gl/core@9.0.28)(@luma.gl/engine@9.0.28(@luma.gl/core@9.0.28)) @@ -5709,6 +5949,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-strict-equal@0.2.0: @@ -6144,6 +6386,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -6243,6 +6487,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.2.2: {} @@ -6520,6 +6766,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + pbf@3.3.0: dependencies: ieee754: 1.2.1 @@ -6618,6 +6866,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-window@2.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -6868,6 +7121,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@1.1.2: {} stylis@4.2.0: {} @@ -6938,6 +7195,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -6945,8 +7204,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7021,6 +7286,27 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vite-node@3.2.4(@types/node@24.3.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-dts@4.5.4(@types/node@24.3.0)(rollup@4.40.1)(typescript@5.8.2)(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2)): dependencies: '@microsoft/api-extractor': 7.55.2(@types/node@24.3.0) @@ -7050,6 +7336,47 @@ snapshots: fsevents: 2.3.3 yaml: 2.8.2 + vitest@3.2.4(@types/node@24.3.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.3.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.15(@types/node@24.3.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.15 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 88f2d98a..b35b5f08 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'viewer' - - 'sites/*' \ No newline at end of file + - 'sites/*' + - 'anndata-zarr' diff --git a/sites/app/package.json b/sites/app/package.json index 73ff1897..506f786a 100644 --- a/sites/app/package.json +++ b/sites/app/package.json @@ -9,7 +9,8 @@ "check": "tsc" }, "dependencies": { - "@biongff/vizarr": "workspace:*", + "@biongff/vizarr": "workspace:viewer", + "@biongff/anndata-zarr": "workspace:anndata-zarr", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.2.0", diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index 88695323..43cdbfe9 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -1,7 +1,24 @@ -import { type ViewState, Vizarr } from "@biongff/vizarr"; +import { type ViewState, Vizarr, type labelColor } from "@biongff/vizarr"; + +//@ts-ignore +//No types provided by anndata-zarr plugin +import { AnndataController, AnndataProvider } from "@biongff/anndata-zarr"; +import CssBaseline from "@mui/material/CssBaseline"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; import debounce from "just-debounce-it"; import * as React from "react"; +import "@biongff/anndata-zarr/dist/anndata-zarr.css"; + +const darkTheme = createTheme({ + palette: { + mode: "dark", + }, + typography: { + fontSize: 12, + }, +}); + function parseViewStateFromUrl(): ViewState | undefined { const url = new URL(window.location.href); const viewStateString = url.searchParams.get("viewState"); @@ -20,15 +37,18 @@ function parseViewStateFromUrl(): ViewState | undefined { export default function App() { const urlString = window.location.href; - const { sources, viewState } = React.useMemo(() => { + const { sources, viewState, anndatas } = React.useMemo(() => { const url = new URL(urlString); const { searchParams } = url; return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), + anndatas: searchParams.getAll("anndata").map((v) => (v ? { url: v } : null)), }; }, [urlString]); + const [colors, setColors] = React.useState((): labelColor[][] => Array(sources.length).fill([])); + // Debounced viewState change handler const handleViewStateChange = React.useMemo( () => @@ -46,9 +66,37 @@ export default function App() { [], ); + const selectCallback = React.useCallback((colorData: labelColor[], i: number) => { + setColors((prev) => { + return prev.map((c, ci) => (ci === i ? colorData : c)); + }); + }, []); + + const anndataControllers = React.useMemo(() => { + return sources.map((_s, i) => { + if (!anndatas?.[i]?.url) return null; + return ( + selectCallback(colorData, i)} + /> + ); + }); + }, [anndatas, sources, selectCallback]); + return ( -
- -
+ + + +
{anndataControllers}
+ +
+
); } diff --git a/sites/app/src/index.css b/sites/app/src/index.css new file mode 100644 index 00000000..308a6312 --- /dev/null +++ b/sites/app/src/index.css @@ -0,0 +1,9 @@ +.container-right { + position: absolute; + top: 1rem; + right: 1rem; + bottom: 1rem; + z-index: 1; + padding: 0.5rem; + overflow-y: auto; +} diff --git a/sites/app/src/main.tsx b/sites/app/src/main.tsx index dc0390d2..c4efe3c7 100644 --- a/sites/app/src/main.tsx +++ b/sites/app/src/main.tsx @@ -2,6 +2,7 @@ import { version } from "@biongff/vizarr"; import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import "./index.css"; console.log(`vizarr v${version}: https://github.com/BioNGFF/vizarr`); diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index b176e62e..f7e900f9 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -10,6 +10,8 @@ export default defineConfig(({ mode }) => ({ ...(mode === "development" ? { "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), + "@biongff/anndata-zarr/dist/anndata-zarr.css": path.resolve(__dirname, "../../anndata-zarr/src/index.css"), + "@biongff/anndata-zarr": path.resolve(__dirname, "../../anndata-zarr/src/index.js"), } : {}), }, diff --git a/viewer/src/api.tsx b/viewer/src/api.tsx index d6284bab..a44c9497 100644 --- a/viewer/src/api.tsx +++ b/viewer/src/api.tsx @@ -27,6 +27,12 @@ type Events = { export type { ViewState, ImageLayerConfig }; +export type labelColor = { + labelValue: number; + rgba: [r: number, g: number, b: number, a: number]; + value?: string | number | null; +}; + export interface VizarrViewer { addImage(config: ImageLayerConfig): void; setViewState(viewState: ViewState): void; diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index b39b3b24..e0f28dac 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -19,7 +19,6 @@ export default function Viewer() { const layers = useAtomValue(layerAtoms); const firstLayer = layers[0] as VizarrLayer; const axisNavigationSnackbar = useAxisNavigation(deckRef, viewport); - const resetViewState = React.useCallback( (layer: VizarrLayer) => { const { deck } = deckRef.current || {}; diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 19743551..7631fbc7 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -5,7 +5,8 @@ import { type PrimitiveAtom, Provider, atom, useAtomValue, useSetAtom } from "jo import React, { useId } from "react"; import { getSourceDataError, sourceDataValid, writeUserErrorMessage } from "../error"; import { ViewStateContext } from "../hooks"; -import { createSourceData } from "../io"; +import { loadSources } from "../io"; +import type { OmeColor } from "../layers/label-layer"; import { type ImageLayerConfig, type ViewState, @@ -24,20 +25,25 @@ export interface VizarrViewerProps { sources?: string[]; viewState?: ViewState; onViewStateChange?: (viewState: ViewState) => void; + labelColours?: OmeColor[][]; } -function VizarrViewerComponent({ sources = [], viewState: initialViewState, onViewStateChange }: VizarrViewerProps) { +function VizarrViewerComponent({ + sources = [], + viewState: initialViewState, + onViewStateChange, + labelColours, +}: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); const setViewStateAtom = useSetAtom(viewStateAtom); const sourceError = useAtomValue(sourceErrorAtom); const redirectObj = useAtomValue(redirectObjAtom); const setSourceError = useSetAtom(sourceErrorAtom); const sourceWarning = useAtomValue(sourceWarningAtom); - React.useEffect(() => { - if (initialViewState) { - setViewStateAtom(initialViewState); - } - }, [initialViewState, setViewStateAtom]); + + if (initialViewState) { + setViewStateAtom(initialViewState); + } const viewStateAtomWithEffect: PrimitiveAtom = atom( (get) => get(viewStateAtom), @@ -53,33 +59,12 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onVi }, ); - const [configs] = React.useState( - sources.map((source, index) => { - const config: ImageLayerConfig = { - source: source, - }; - return config; - }), - ); - React.useEffect(() => { - async function loadSources() { - const results = await Promise.allSettled( - configs.map(async (config, index) => { - const sourceData = await createSourceData(config); - const id = Math.random().toString(36).slice(2); - if (!sourceData.name) { - sourceData.name = `image_${index}`; - } - return { id, ...sourceData }; - }), - ); - let sourceDatas = []; - + loadSources(sources, labelColours).then((results) => { if (!sourceDataValid(results)) { setSourceError(writeUserErrorMessage(getSourceDataError(results))); } - + let sourceDatas = []; for (const res of results) { if (res.status === "fulfilled") { sourceDatas.push(res.value); @@ -87,12 +72,11 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onVi console.error(res.reason); } } - sourceDatas = sourceDatas.filter((s) => s !== null); - setSourceInfo(sourceDatas); - } + const sourceData = sourceDatas.filter((s) => s !== null); + setSourceInfo(sourceData); + }); + }, [sources, labelColours, setSourceInfo, setSourceError]); - loadSources(); - }, [configs, setSourceInfo, setSourceError]); return ( <> {redirectObj === null && ( @@ -125,7 +109,7 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onVi

)} - {sourceWarning.length && + {!!sourceWarning.length && sourceWarning.map((warning, index) => { return ; })} diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index 5fe6d55e..9a674bfd 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -5,6 +5,6 @@ export { default as Vizarr } from "./components/VizarrViewer"; export type { VizarrViewerProps } from "./components/VizarrViewer"; export { createViewer } from "./api"; -export type { VizarrViewer } from "./api"; +export type { VizarrViewer, labelColor } from "./api"; export type { ViewState, ImageLayerConfig } from "./state"; diff --git a/viewer/src/io.ts b/viewer/src/io.ts index 940e230f..63b63533 100644 --- a/viewer/src/io.ts +++ b/viewer/src/io.ts @@ -3,7 +3,7 @@ import { ZarrPixelSource } from "./ZarrPixelSource"; import { loadOmeMultiscales, loadPlate, loadWell } from "./ome"; import * as utils from "./utils"; -import { DEFAULT_LABEL_OPACITY } from "./layers/label-layer"; +import { DEFAULT_LABEL_OPACITY, type OmeColor } from "./layers/label-layer"; import type { BaseLayerProps } from "./layers/viv-layers"; import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from "./state"; @@ -222,7 +222,7 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L let labels = undefined; if (source.labels && source.labels.length > 0) { labels = source.labels.map((label, i) => ({ - on: false, + on: label.on ? label.on : false, transformSourceSelection: getSourceSelectionTransform(label.loader[0], source.loader[0]), layerProps: { id: `${source.id}_${i}`, @@ -233,7 +233,6 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L }, })); } - return { kind: "multiscale", layerProps: { @@ -274,3 +273,26 @@ function getSourceSelectionTransform( ); }; } + +export async function loadSources(sources: string[], labelColors?: OmeColor[][]) { + const results = await Promise.allSettled( + sources.map(async (source, index) => { + const sourceData = await createSourceData({ source: source }); + const id = Math.random().toString(36).slice(2); + if (!sourceData.name) { + sourceData.name = `image_${index}`; + } + if (labelColors?.[index].length) { + if (!sourceData.labels || !sourceData.labels.length) { + throw new utils.AssertionError("Feature colours provided but source image has no label."); + } + //Really not the best way to do this but the layer state is heavily wrapped up in + //being derived directly from the sourceData and would require a fairly large refactor to find + sourceData.labels[0].colors = labelColors[index]; + sourceData.labels[0].on = true; + } + return { id, ...sourceData }; + }), + ); + return results; +} diff --git a/viewer/src/state.ts b/viewer/src/state.ts index dab72dd2..8589b65c 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -62,6 +62,7 @@ export type ImageLabels = Array<{ loader: ZarrPixelSource[]; modelMatrix: Matrix4; colors?: ReadonlyArray; + on?: boolean; }>; export type SourceData = { diff --git a/viewer/tests/features.test.js b/viewer/tests/features.test.js new file mode 100644 index 00000000..44667330 --- /dev/null +++ b/viewer/tests/features.test.js @@ -0,0 +1,37 @@ +import { AssertionError } from "node:assert"; +import { expect, test } from "vitest"; +import { loadSources } from "../src/io"; +import { range } from "../src/utils"; + +const labelImageURL = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr"; +const imageURL = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpD_chicken_embryo_MIP.ome.zarr"; +const labelIds = range(61); + +function generateLabelColors(labelIds) { + return labelIds.map((id) => { + return { + labelValue: id + 1, + rgba: [ + Math.floor(Math.random() * 250), + Math.floor(Math.random() * 250), + Math.floor(Math.random() * 250), + Math.floor(Math.random() * 250), + ], + value: Math.random(), + }; + }); +} + +test("Can create source data with externally-defined label colours", async () => { + const labelColours = generateLabelColors(labelIds); + const sources = await loadSources([labelImageURL], [labelColours]); + expect(sources[0].value.labels[0].colors).toBe(labelColours); +}); + +test("Attempting to add externally-defined label colours to image without label leads to an error", async () => { + const labelColours = generateLabelColors(labelIds); + const sources = await loadSources([imageURL], [labelColours]); + expect(sources[0].status).toBe("rejected"); + //Requires AssertionError to provide correct error message to user + expect(sources[0].reason.name).toBe("AssertionError"); +});