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");
+});