) {
- const [layer, setLayer] = useAtom(layerAtom);
- const sourceData = useAtomValue(sourceAtom);
+function AxisSlider(props: { axisIndex: number; max: number }) {
+ const { axisIndex, max } = props;
+ const sourceData = useSourceValue();
+ const [layer, setLayer] = useLayer();
const { axis_labels } = sourceData;
- let axisLabel = axis_labels[axisIndex];
- if (axisLabel === "t" || axisLabel === "z") {
- axisLabel = axisLabel.toUpperCase();
- }
+ const axisLabel = capitalize(axis_labels[axisIndex]);
+
// state of the slider to update UI while dragging
const [value, setValue] = React.useState(0);
@@ -40,53 +23,37 @@ function AxisSlider({ sourceAtom, layerAtom, axisIndex, max }: ControllerProps {
- setLayer((prev) => {
- let layerProps = { ...prev.layerProps };
- // for each channel, update index of this axis
- layerProps.selections = layerProps.selections.map((ch) => {
- let new_ch = [...ch];
- new_ch[axisIndex] = value;
- return new_ch;
- });
- return { ...prev, layerProps };
- });
- };
-
- const handleDrag = (_: ChangeEvent, value: number | number[]) => {
- setValue(value as number);
- };
+ let id = `axis-${axisIndex}-${sourceData.id}-slider`;
return (
- <>
-
-
-
-
-
- {axisLabel}: {value}/{max}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
+
+
+
+
+
+
+ setValue(update)}
+ onValueCommit={([update]) => {
+ setLayer((prev) => {
+ let layerProps = { ...prev.layerProps };
+ // for each channel, update index of this axis
+ layerProps.selections = layerProps.selections.map((ch) => {
+ return ch.with(axisIndex, update);
+ });
+ return { ...prev, layerProps };
+ });
+ }}
+ min={0}
+ max={max}
+ step={1}
+ />
+
+
);
}
diff --git a/src/components/LayerController/AxisSliders.tsx b/src/components/LayerController/AxisSliders.tsx
deleted file mode 100644
index 05cf27a..0000000
--- a/src/components/LayerController/AxisSliders.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Divider, Grid } from "@material-ui/core";
-import { useAtomValue } from "jotai";
-import React from "react";
-import type { ControllerProps } from "../../state";
-import AxisSlider from "./AxisSlider";
-
-function AxisSliders({ sourceAtom, layerAtom }: ControllerProps) {
- const sourceData = useAtomValue(sourceAtom);
- const { axis_labels, channel_axis, loader } = sourceData;
-
- const sliders = axis_labels
- .slice(0, -2) // ignore last two axes, [y,x]
- .map((name, i): [string, number, number] => [name, i, loader[0].shape[i]]) // capture the name, index, and size of non-yx dims
- .filter((d) => {
- if (d[1] === channel_axis) return false; // ignore channel_axis (for OME-Zarr channel_axis === 1)
- if (d[2] > 1) return true; // keep if size > 1
- return false; // otherwise ignore as well
- })
- .map(([name, i, size]) => (
-
- ));
-
- if (sliders.length === 0) return null;
- return (
- <>
- {sliders}
-
- >
- );
-}
-
-export default AxisSliders;
diff --git a/src/components/LayerController/ChannelController.tsx b/src/components/LayerController/ChannelController.tsx
index 20bcfd2..593b8f7 100644
--- a/src/components/LayerController/ChannelController.tsx
+++ b/src/components/LayerController/ChannelController.tsx
@@ -1,90 +1,74 @@
-import { Grid, IconButton, Slider, Typography } from "@material-ui/core";
-import { RadioButtonChecked, RadioButtonUnchecked } from "@material-ui/icons";
-import { useAtom } from "jotai";
-import { useAtomValue } from "jotai";
-import React from "react";
-import type { ChangeEvent } from "react";
-import type { ControllerProps } from "../../state";
-import ChannelOptions from "./ChannelOptions";
-
-interface ChannelConfig {
- channelIndex: number;
-}
-
-function ChannelController({ sourceAtom, layerAtom, channelIndex }: ControllerProps) {
- const sourceData = useAtomValue(sourceAtom);
- const [layer, setLayer] = useAtom(layerAtom);
+import * as React from "react";
- const handleContrastChange = (_: ChangeEvent, v: number | number[]) => {
- setLayer((prev) => {
- const contrastLimits = [...prev.layerProps.contrastLimits];
- contrastLimits[channelIndex] = v as [number, number];
- return { ...prev, layerProps: { ...prev.layerProps, contrastLimits } };
- });
- };
-
- const handleVisibilityChange = () => {
- setLayer((prev) => {
- const channelsVisible = [...prev.layerProps.channelsVisible];
- channelsVisible[channelIndex] = !channelsVisible[channelIndex];
- return { ...prev, layerProps: { ...prev.layerProps, channelsVisible } };
- });
- };
+import { Slider } from "@/components/ui/slider";
+import { useLayer, useSourceValue } from "@/hooks";
+import ChannelOptions from "./ChannelOptions";
- const lp = layer.layerProps;
+function ChannelController(props: { channelIndex: number }) {
+ const { channelIndex: i } = props;
+ const sourceData = useSourceValue();
+ const [layer, setLayer] = useLayer();
- // Material slider tries to sort in place. Need to copy.
- const value = [...lp.contrastLimits[channelIndex]];
- const color = `rgb(${lp.colormap ? [255, 255, 255] : lp.colors[channelIndex]})`;
- const on = lp.channelsVisible[channelIndex];
- const [min, max] = lp.contrastLimitsRange[channelIndex];
+ const value = layer.layerProps.contrastLimits[i];
+ const color = `rgb(${layer.layerProps.colormap ? [255, 255, 255] : layer.layerProps.colors[i]})`;
+ const on = layer.layerProps.channelsVisible[i];
+ const [min, max] = layer.layerProps.contrastLimitsRange[i];
const { channel_axis, names } = sourceData;
- const selection = lp.selections[channelIndex];
- const nameIndex = Number.isInteger(channel_axis) ? selection[channel_axis as number] : 0;
+ const selection = layer.layerProps.selections[i];
+ const nameIndex = Number.isInteger(channel_axis) && channel_axis !== null ? selection[channel_axis] : 0;
const label = names[nameIndex];
+
return (
<>
-
-
-
-
- {label}
-
-
-
-
-
-
-
-
-
-
- {on ? : }
-
-
-
-
+
+
+
+
+
+
{
+ setLayer((prev) => ({
+ ...prev,
+ layerProps: {
+ ...prev.layerProps,
+ contrastLimits: prev.layerProps.contrastLimits.with(i, [...update]),
+ },
+ }));
+ }}
+ color={color}
+ min={min}
+ max={max}
+ step={0.01}
+ />
+
>
);
}
diff --git a/src/components/LayerController/ChannelOptions.tsx b/src/components/LayerController/ChannelOptions.tsx
index 43263da..f197f64 100644
--- a/src/components/LayerController/ChannelOptions.tsx
+++ b/src/components/LayerController/ChannelOptions.tsx
@@ -1,186 +1,153 @@
-import { Divider, IconButton, Input, NativeSelect, Paper, Popover, Typography } from "@material-ui/core";
-import { MoreHoriz, Remove } from "@material-ui/icons";
-import { withStyles } from "@material-ui/styles";
-import { useAtom } from "jotai";
-import { useAtomValue } from "jotai";
-import React, { useState } from "react";
-import type { ChangeEvent, MouseEvent } from "react";
-import type { ControllerProps } from "../../state";
-import ColorPalette from "./ColorPalette";
-
-const DenseInput = withStyles({
- root: {
- width: "5.5em",
- fontSize: "0.7em",
- },
-})(Input);
-
-interface Props {
- channelIndex: number;
-}
-
-function ChannelOptions({ sourceAtom, layerAtom, channelIndex }: ControllerProps) {
- const sourceData = useAtomValue(sourceAtom);
- const [layer, setLayer] = useAtom(layerAtom);
- const [anchorEl, setAnchorEl] = useState(null);
- const { channel_axis, names } = sourceData;
-
- const handleClick = (event: MouseEvent) => {
- setAnchorEl(event.currentTarget);
- };
-
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- const handleColorChange = (rgb: [number, number, number]) => {
- setLayer((prev) => {
- const colors = [...prev.layerProps.colors];
- colors[channelIndex] = rgb;
- return { ...prev, layerProps: { ...prev.layerProps, colors } };
- });
- };
-
- const handleContrastLimitChange = (event: ChangeEvent) => {
- const targetId = event.target.id;
- let value = +event.target.value;
-
- // Only let positive values
- if (value < 0) value = 0;
-
- setLayer((prev) => {
- // Need to move sliders in if contrast limits are narrower
- const contrastLimitsRange = [...prev.layerProps.contrastLimitsRange];
- const contrastLimits = [...prev.layerProps.contrastLimits];
-
- const [cmin, cmax] = contrastLimitsRange[channelIndex];
- const [smin, smax] = contrastLimits[channelIndex];
-
- // Calculate climit update
- const [umin, umax] = targetId === "min" ? [value, cmax] : [cmin, value];
-
- // Update sliders if needed
- if (umin > smin) contrastLimits[channelIndex] = [umin, smax];
- if (umax < smax) contrastLimits[channelIndex] = [smin, umax];
-
- // Update channel constrast limits range
- contrastLimitsRange[channelIndex] = [umin, umax];
-
+import { Cross2Icon, DotsHorizontalIcon } from "@radix-ui/react-icons";
+import * as React from "react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { useLayer, useSourceValue } from "@/hooks";
+import { assert, COLORS, clamp, hexToRGB } from "@/utils";
+
+const RGB_COLORS: [string, string][] = Object.entries(COLORS);
+
+function ChannelOptions(props: { channelIndex: number }) {
+ const { channelIndex: i } = props;
+ const info = useSourceValue();
+ const [layer, setLayer] = useLayer();
+ const { channel_axis, names } = info;
+ assert(channel_axis !== null, "channel_axis is null");
+
+ const handleContrastLimitChange = (unclamped: number, which: "min" | "max") => {
+ const value = clamp(unclamped, { min: 0 });
+ setLayer(({ layerProps, ...rest }) => {
+ const lims = layerProps.contrastLimitsRange[i];
+ const vals = layerProps.contrastLimits[i];
+ const [min, max] = which === "min" ? [value, lims[1]] : [lims[0], value];
return {
- ...prev,
- layerProps: { ...prev.layerProps, contrastLimits, contrastLimitsRange },
- };
- });
- };
-
- const handleRemove = () => {
- setLayer((prev) => {
- const { layerProps } = prev;
- const colors = [...layerProps.colors];
- const contrastLimits = [...layerProps.contrastLimits];
- const contrastLimitsRange = [...layerProps.contrastLimitsRange];
- const selections = [...layerProps.selections];
- const channelsVisible = [...layerProps.channelsVisible];
- colors.splice(channelIndex, 1);
- contrastLimits.splice(channelIndex, 1);
- contrastLimitsRange.splice(channelIndex, 1);
- selections.splice(channelIndex, 1);
- channelsVisible.splice(channelIndex, 1);
- return {
- ...prev,
+ ...rest,
layerProps: {
...layerProps,
- colors,
- selections,
- channelsVisible,
- contrastLimits,
- contrastLimitsRange,
+ contrastLimits: layerProps.contrastLimits.with(i, [clamp(vals[0], { min }), clamp(vals[1], { min: 0, max })]),
+ contrastLimitsRange: layerProps.contrastLimitsRange.with(i, [min, max]),
},
};
});
};
- const handleSelectionChange = (event: ChangeEvent) => {
- setLayer((prev) => {
- const selections = [...prev.layerProps.selections];
- const channelSelection = [...selections[channelIndex]];
- if (Number.isInteger(channel_axis)) {
- channelSelection[channel_axis as number] = +event.target.value;
- selections[channelIndex] = channelSelection;
- }
- return { ...prev, layerProps: { ...prev.layerProps, selections } };
- });
+ const handleSelectionChange = (idx: number) => {
+ setLayer(({ layerProps, ...rest }) => ({
+ ...rest,
+ layerProps: {
+ ...layerProps,
+ selections: layerProps.selections.with(i, layerProps.selections[i].with(channel_axis, idx)),
+ },
+ }));
};
- const open = Boolean(anchorEl);
- const id = open ? `channel-${channelIndex}-${sourceData.name}-options` : undefined;
- const [min, max] = layer.layerProps.contrastLimitsRange[channelIndex];
+ const [min, max] = layer.layerProps.contrastLimitsRange[i];
return (
- <>
-
-
-
-
-
-
- remove:
-
-
-
-
-
- selection:
-
-
+
+
+
+
+
+ remove:
+
+
+
+ channel:
+
+
-
- contrast limits:
-
-
-
-
- color:
-
-
-
-
-
-
- >
+
+
+
+ contrast limits:
+
+ {
+ if (e.key !== "Enter") return;
+ const value = +e.currentTarget.value;
+ handleContrastLimitChange(value, "min");
+ }}
+ onBlur={(e) => handleContrastLimitChange(+e.currentTarget.value, "min")}
+ />
+ {
+ if (e.key !== "Enter") return;
+ const value = +e.currentTarget.value;
+ handleContrastLimitChange(value, "max");
+ }}
+ onBlur={(e) => handleContrastLimitChange(+e.currentTarget.value, "max")}
+ />
+
+ color:
+
+
+ {RGB_COLORS.map(([name, rgb]) => (
+
+
+
);
}
diff --git a/src/components/LayerController/ColorPalette.tsx b/src/components/LayerController/ColorPalette.tsx
deleted file mode 100644
index b9c77fe..0000000
--- a/src/components/LayerController/ColorPalette.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { IconButton } from "@material-ui/core";
-import { Lens } from "@material-ui/icons";
-import { makeStyles } from "@material-ui/styles";
-import React from "react";
-import { COLORS, hexToRGB } from "../../utils";
-
-const useStyles = makeStyles(() => ({
- container: {
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- padding: "2px",
- },
- button: {
- padding: "3px",
- width: "16px",
- height: "16px",
- },
-}));
-
-const RGB_COLORS: [string, [number, number, number]][] = Object.entries(COLORS).map(([name, hex]) => [
- name,
- hexToRGB(hex),
-]);
-function ColorPalette({ handleChange }: { handleChange: (c: [number, number, number]) => void }) {
- const classes = useStyles();
- return (
-
- {RGB_COLORS.map(([name, rgb]) => {
- return (
- handleChange(rgb)}>
-
-
- );
- })}
-
- );
-}
-
-export default ColorPalette;
diff --git a/src/components/LayerController/Content.tsx b/src/components/LayerController/Content.tsx
deleted file mode 100644
index b9e75f7..0000000
--- a/src/components/LayerController/Content.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { AccordionDetails, Divider, Grid, Typography } from "@material-ui/core";
-import { withStyles } from "@material-ui/styles";
-import { useAtomValue } from "jotai";
-import React from "react";
-
-import AcquisitionController from "./AcquisitionController";
-import AddChannelButton from "./AddChannelButton";
-import AxisSliders from "./AxisSliders";
-import ChannelController from "./ChannelController";
-import OpacitySlider from "./OpacitySlider";
-
-import type { ControllerProps } from "../../state";
-import { range } from "../../utils";
-
-const Details = withStyles({
- root: {
- padding: "2px 5px",
- borderLeft: "1px solid rgba(150, 150, 150, .2)",
- borderRight: "1px solid rgba(150, 150, 150, .2)",
- },
-})(AccordionDetails);
-
-function Content({ sourceAtom, layerAtom }: ControllerProps) {
- const layer = useAtomValue(layerAtom);
- const nChannels = layer.layerProps.selections.length;
- return (
-
-
-
-
-
-
- opacity:
-
-
-
-
-
-
-
-
-
-
- channels:
-
-
-
-
-
-
-
- {range(nChannels).map((i) => (
-
- ))}
-
-
-
- );
-}
-
-export default Content;
diff --git a/src/components/LayerController/Header.tsx b/src/components/LayerController/Header.tsx
deleted file mode 100644
index 82d3b45..0000000
--- a/src/components/LayerController/Header.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { AccordionSummary, Typography } from "@material-ui/core";
-import { withStyles } from "@material-ui/styles";
-import { useAtomValue } from "jotai";
-import React from "react";
-import type { ControllerProps } from "../../state";
-import LayerVisibilityButton from "./LayerVisibilityButton";
-
-const DenseAccordionSummary = withStyles({
- root: {
- borderBottom: "1px solid rgba(150, 150, 150, .125)",
- backgroundColor: "rgba(150, 150, 150, 0.25)",
- display: "block",
- padding: "0 3px",
- height: 27,
- minHeight: 27,
- overflow: "hidden",
- transition: "none",
- "&$expanded": {
- minHeight: 27,
- },
- },
- content: {
- margin: 0,
- "&$expanded": {
- margin: 0,
- },
- },
- expanded: {},
-})(AccordionSummary);
-
-interface Props {
- name: string;
-}
-
-function Header({ sourceAtom, layerAtom, name }: ControllerProps) {
- const sourceData = useAtomValue(sourceAtom);
- const label = `layer-controller-${sourceData.id}`;
- return (
-
-
-
-
- {name}
-
-
-
- );
-}
-
-export default Header;
diff --git a/src/components/LayerController/LayerVisibilityButton.tsx b/src/components/LayerController/LayerVisibilityButton.tsx
deleted file mode 100644
index 4b7acd1..0000000
--- a/src/components/LayerController/LayerVisibilityButton.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { IconButton } from "@material-ui/core";
-import { Visibility, VisibilityOff } from "@material-ui/icons";
-import { useAtom } from "jotai";
-import { useAtomValue } from "jotai";
-import React from "react";
-import type { MouseEvent } from "react";
-import type { ControllerProps } from "../../state";
-
-function LayerVisibilityButton({ sourceAtom, layerAtom }: ControllerProps) {
- const sourceData = useAtomValue(sourceAtom);
- const [layer, setLayer] = useAtom(layerAtom);
- const toggle = (event: MouseEvent) => {
- event.stopPropagation();
- setLayer((prev) => {
- const on = !prev.on;
- return { ...prev, on };
- });
- };
- return (
-
- {layer.on ? : }
-
- );
-}
-
-export default LayerVisibilityButton;
diff --git a/src/components/LayerController/OpacitySlider.tsx b/src/components/LayerController/OpacitySlider.tsx
deleted file mode 100644
index e09a40f..0000000
--- a/src/components/LayerController/OpacitySlider.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Slider } from "@material-ui/core";
-import { withStyles } from "@material-ui/styles";
-import { useAtom } from "jotai";
-import React from "react";
-import type { ChangeEvent } from "react";
-import type { ControllerProps } from "../../state";
-
-const DenseSlider = withStyles({
- root: {
- color: "white",
- padding: "10px 0px 5px 0px",
- marginRight: "5px",
- },
- active: {
- boxshadow: "0px 0px 0px 8px rgba(158, 158, 158, 0.16)",
- },
-})(Slider);
-
-function OpacitySlider({ layerAtom }: ControllerProps) {
- const [layer, setLayer] = useAtom(layerAtom);
- const handleChange = (_: ChangeEvent, value: number | number[]) => {
- const opacity = value as number;
- setLayer((prev) => ({ ...prev, layerProps: { ...prev.layerProps, opacity } }));
- };
- return ;
-}
-
-export default OpacitySlider;
diff --git a/src/components/LayerController/index.tsx b/src/components/LayerController/index.tsx
index 70f3c09..27d4ab3 100644
--- a/src/components/LayerController/index.tsx
+++ b/src/components/LayerController/index.tsx
@@ -1,42 +1,111 @@
-import MuiAccordion from "@material-ui/core/Accordion";
-import { withStyles } from "@material-ui/styles";
-import { useAtomValue } from "jotai";
-import React from "react";
+import { EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons";
+import AcquisitionSelect from "./AcquisitionController";
+import AddChannelButton from "./AddChannelButton";
+import AxisSlider from "./AxisSlider";
+import ChannelController from "./ChannelController";
-import type { ControllerProps } from "../../state";
-import { layerFamilyAtom } from "../../state";
-import Content from "./Content";
-import Header from "./Header";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Separator } from "@/components/ui/separator";
+import { Slider } from "@/components/ui/slider";
+import { useLayer, useSourceValue } from "@/hooks";
+import { range } from "@/utils";
-const Accordion = withStyles({
- root: {
- borderBottom: "1px solid rgba(150, 150, 150, .2)",
- width: 200,
- boxshadow: "none",
- "&:not(:last-child)": {
- borderBottom: 0,
- },
- "&:before": {
- display: "none",
- },
- "&$expanded": {
- margin: 0,
- padding: 0,
- },
- },
- expanded: {
- padding: 1,
- },
-})(MuiAccordion);
+/** Get the { name, idx, size } for each axis that is not the channel_axis and has size > 1 */
+function axisSliders(info: {
+ axis_labels: string[];
+ channel_axis: number | null;
+ loader: Array<{ shape: number[] }>;
+}): Array<{ name: string; idx: number; size: number }> {
+ const { axis_labels, channel_axis, loader } = info;
+ return axis_labels
+ .slice(0, -2) // ignore last two axes, [y,x]
+ .map((name, idx) => ({ name, idx, size: loader[0].shape[idx] }))
+ .filter(({ idx, size }) => {
+ // ignore channel_axis (for OME-Zarr channel_axis === 1)
+ if (idx === channel_axis) return false;
+ // keep if size > 1
+ return size > 1;
+ });
+}
-function LayerController({ sourceAtom }: Omit) {
- const sourceInfo = useAtomValue(sourceAtom);
- const layerAtom = layerFamilyAtom(sourceInfo);
- const { name = "" } = sourceInfo;
+function LayerController() {
+ const info = useSourceValue();
+ const [layer, setLayer] = useLayer();
+ const nChannels = layer.layerProps.selections.length;
+ const { name = "" } = info;
return (
-
-
-
+
+
+
+
+
+ {name}
+
+
+
+ {(info.acquisitions?.length ?? 0) > 0 ? (
+ <>
+ {/* biome-ignore lint/style/noNonNullAssertion: Ok because we assert above */}
+
+
+ >
+ ) : null}
+
+
+ {
+ const [opacity] = update;
+ setLayer((prev) => ({ ...prev, layerProps: { ...prev.layerProps, opacity } }));
+ }}
+ min={0}
+ max={1}
+ step={0.01}
+ />
+
+
+ {axisSliders(info).map(({ name, idx, size }) => (
+
+ ))}
+
+
+
+
+ {range(nChannels).map((i) => (
+
+ ))}
+
+
+
);
}
diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx
index 69d7255..a474529 100644
--- a/src/components/Menu.tsx
+++ b/src/components/Menu.tsx
@@ -1,56 +1,33 @@
-import { Grid, IconButton } from "@material-ui/core";
-import { Add, Remove } from "@material-ui/icons";
-import { makeStyles } from "@material-ui/styles";
import { useAtomValue } from "jotai";
import React, { useReducer } from "react";
import { sourceInfoAtomAtoms } from "../state";
import LayerController from "./LayerController";
-const useStyles = makeStyles({
- root: {
- zIndex: 1,
- position: "absolute",
- backgroundColor: "rgba(0, 0, 0, 0.7)",
- borderRadius: "5px",
- left: "5px",
- top: "5px",
- },
- scroll: {
- maxHeight: 500,
- overflowX: "hidden",
- overflowY: "scroll",
- "&::-webkit-scrollbar": {
- display: "none",
- background: "transparent",
- },
- scrollbarWidth: "none",
- flexDirection: "column",
- },
-});
+import { LayerContext } from "@/hooks";
+import { cn } from "@/lib/utils";
+import { MinusIcon, PlusIcon } from "@radix-ui/react-icons";
-function Menu(props: { open?: boolean }) {
- const sourceAtoms = useAtomValue(sourceInfoAtomAtoms);
- const [hidden, toggle] = useReducer((v) => !v, !(props.open ?? true));
- const classes = useStyles();
+function Menu({ open = true }: { open?: boolean }) {
+ const atoms = useAtomValue(sourceInfoAtomAtoms);
+ const [hidden, toggle] = useReducer((v) => !v, !open);
return (
-
-
-
- {hidden ? : }
-
-
- {sourceAtoms.map((sourceAtom) => (
-
- ))}
-
-
+
+
+
+ {atoms.map((sourceAtom) => (
+
+
+
+ ))}
+
);
}
diff --git a/src/components/Viewer.tsx b/src/components/Viewer.tsx
index f2fef3f..5196b68 100644
--- a/src/components/Viewer.tsx
+++ b/src/components/Viewer.tsx
@@ -1,12 +1,11 @@
import DeckGL from "deck.gl";
import { type Layer, OrthographicView } from "deck.gl";
-import { type WritableAtom, useAtom } from "jotai";
import { useAtomValue } from "jotai";
import * as React from "react";
+import { useViewState } from "@/hooks";
import type { LayerProps } from "@deck.gl/core/lib/layer";
import type { ZarrPixelSource } from "../ZarrPixelSource";
-import type { ViewState } from "../state";
import { layerAtoms } from "../state";
import { fitBounds, isInterleaved } from "../utils";
@@ -28,11 +27,8 @@ function getLayerSize(props: Data) {
return { height, width, maxZoom };
}
-function WrappedViewStateDeck(props: {
- layers: Array
;
- viewStateAtom: WritableAtom;
-}) {
- const [viewState, setViewState] = useAtom(props.viewStateAtom);
+function WrappedViewStateDeck(props: { layers: Array }) {
+ const [viewState, setViewState] = useViewState();
const deckRef = React.useRef(null);
const firstLayerProps = props.layers[0]?.props;
@@ -46,11 +42,6 @@ function WrappedViewStateDeck(props: {
setViewState(bounds);
}
- // Enables screenshots of the canvas: https://github.com/visgl/deck.gl/issues/2200
- const glOptions: WebGLContextAttributes = {
- preserveDrawingBuffer: true,
- };
-
return (
setViewState(e.viewState)}
views={[new OrthographicView({ id: "ortho", controller: true })]}
- glOptions={glOptions}
+ glOptions={{
+ // Enables screenshots of the canvas: https://github.com/visgl/deck.gl/issues/2200
+ preserveDrawingBuffer: true,
+ }}
/>
);
}
-function Viewer({ viewStateAtom }: { viewStateAtom: WritableAtom }) {
+function Viewer() {
const layerConstructors = useAtomValue(layerAtoms);
// @ts-expect-error - Viv types are giving up an issue
const layers: Array = layerConstructors.map((layer) => {
return !layer.on ? null : new layer.Layer(layer.layerProps);
});
- return ;
+ return ;
}
export default Viewer;
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..94c9f8a
--- /dev/null
+++ b/src/components/ui/accordion.tsx
@@ -0,0 +1,51 @@
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDownIcon } from "@radix-ui/react-icons";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..0187ec1
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,48 @@
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
+ destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-8",
+ icon: "h-9 w-9",
+ "icon-sm": "h-6 w-6",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return ;
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..ef73e8f
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef>(
+ ({ className, ...props }, ref) => ,
+);
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..d88afdf
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(({ className, type, ...props }, ref) => {
+ return (
+
+ );
+});
+Input.displayName = "Input";
+
+export { Input };
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..ddca21e
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverAnchor = PopoverPrimitive.Anchor;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..c498015
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,142 @@
+import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..f90af38
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,20 @@
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
+
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx
new file mode 100644
index 0000000..aa57bec
--- /dev/null
+++ b/src/components/ui/slider.tsx
@@ -0,0 +1,34 @@
+import * as SliderPrimitive from "@radix-ui/react-slider";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Slider = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, color, ...props }, ref) => {
+ const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max];
+ return (
+
+
+
+
+ {initialValue.map((_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Ok because the array is static
+
+
+
+ ))}
+
+ );
+});
+Slider.displayName = SliderPrimitive.Root.displayName;
+
+export { Slider };
diff --git a/src/hooks.ts b/src/hooks.ts
new file mode 100644
index 0000000..8099770
--- /dev/null
+++ b/src/hooks.ts
@@ -0,0 +1,50 @@
+import { type PrimitiveAtom, useAtom, useAtomValue } from "jotai";
+import * as React from "react";
+
+import { type SourceData, type ViewState, layerFamilyAtom } from "./state";
+import { assert } from "./utils";
+
+type WithId = { id: string } & T;
+type SourceDataAtom = PrimitiveAtom>;
+type ViewStateAtom = PrimitiveAtom;
+
+export const LayerContext = React.createContext(null);
+export const ViewStateContext = React.createContext(null);
+
+function useSourceAtom(): SourceDataAtom {
+ const sourceAtom = React.useContext(LayerContext);
+ assert(sourceAtom !== null, "useSourceAtom must be used within a LayerContext.Provider");
+ return sourceAtom;
+}
+
+function useLayerAtom() {
+ const sourceAtom = useSourceAtom();
+ const sourceInfo = useAtomValue(sourceAtom);
+ return layerFamilyAtom(sourceInfo);
+}
+
+export function useSourceValue() {
+ const sourceAtom = useSourceAtom();
+ return useAtomValue(sourceAtom);
+}
+
+export function useSource() {
+ const sourceAtom = useSourceAtom();
+ return useAtom(sourceAtom);
+}
+
+export function useLayer() {
+ const layerAtom = useLayerAtom();
+ return useAtom(layerAtom);
+}
+
+export function useLayerValue() {
+ const layerAtom = useLayerAtom();
+ return useAtomValue(layerAtom);
+}
+
+export function useViewState() {
+ const viewStateAtom = React.useContext(ViewStateContext);
+ assert(viewStateAtom !== null, "useViewState must be used within a ViewStateContext.Provider");
+ return useAtom(viewStateAtom);
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..a02a2cd
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,89 @@
+@import "tailwindcss";
+
+@layer base {
+ :root {
+ --background: 240 10% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 240 10% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 240 5.9% 10%;
+ --secondary: 240 3.7% 15.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 240 3.7% 15.9%;
+ --muted-foreground: 240 5% 64.9%;
+ --accent: 240 3.7% 15.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 240 3.7% 15.9%;
+ --input: 240 3.7% 15.9%;
+ --ring: 240 4.9% 83.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ }
+}
+
+@theme {
+ --color-border: hsl(var(--border));
+ --color-input: hsl(var(--input));
+ --color-ring: hsl(var(--ring));
+ --color-background: hsl(var(--background));
+ --color-foreground: hsl(var(--foreground));
+
+ --color-primary: hsl(var(--primary));
+ --color-primary-foreground: hsl(var(--primary-foreground));
+
+ --color-secondary: hsl(var(--secondary));
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
+
+ --color-destructive: hsl(var(--destructive));
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
+
+ --color-muted: hsl(var(--muted));
+ --color-muted-foreground: hsl(var(--muted-foreground));
+
+ --color-accent: hsl(var(--accent));
+ --color-accent-foreground: hsl(var(--accent-foreground));
+
+ --color-popover: hsl(var(--popover));
+ --color-popover-foreground: hsl(var(--popover-foreground));
+
+ --color-card: hsl(var(--card));
+ --color-card-foreground: hsl(var(--card-foreground));
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+
+ --font-family-poppins: var(--font-poppins);
+ --font-family-inter: var(--font-inter);
+
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
+
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
+
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+
+ to {
+ height: "0";
+ }
+ }
+}
diff --git a/src/index.tsx b/src/index.tsx
index 4f2114c..3309df1 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,4 +1,3 @@
-import { ThemeProvider } from "@material-ui/styles";
import { Provider, atom } from "jotai";
import { useSetAtom } from "jotai";
import * as React from "react";
@@ -7,12 +6,14 @@ import ReactDOM from "react-dom/client";
import Menu from "./components/Menu";
import Viewer from "./components/Viewer";
import "./codecs/register";
-import { type ImageLayerConfig, type ViewState, addImageAtom, atomWithEffect } from "./state";
-import theme from "./theme";
+import { type ImageLayerConfig, type ViewState, addImageAtom } from "./state";
import { defer, typedEmitter } from "./utils";
export { version } from "../package.json";
+import "./index.css";
+import { ViewStateContext } from "./hooks";
+
type Events = {
viewStateChange: ViewState;
};
@@ -27,11 +28,23 @@ export interface VizarrViewer {
}
export function createViewer(element: HTMLElement, options: { menuOpen?: boolean } = {}): Promise {
+ const shadowRoot = element.attachShadow({ mode: "open" });
+ const link = Object.assign(document.createElement("link"), {
+ rel: "stylesheet",
+ href: new URL(/* @vite-ignore */ "index.css", import.meta.url).href,
+ });
+ shadowRoot.appendChild(link);
+
const ref = React.createRef();
const emitter = typedEmitter();
- const viewStateAtom = atomWithEffect(
- atom(undefined),
- ({ zoom, target }) => emitter.emit("viewStateChange", { zoom, target }),
+ const viewStateAtom = atom(undefined);
+ const viewStateAtomWithUrlSync = atom(
+ (get) => get(viewStateAtom),
+ (get, set, update) => {
+ const next = typeof update === "function" ? update(get(viewStateAtom)) : update;
+ next && emitter.emit("viewStateChange", { target: next.target, zoom: next.zoom });
+ set(viewStateAtom, next);
+ },
);
const { promise, resolve } = defer();
@@ -54,19 +67,17 @@ export function createViewer(element: HTMLElement, options: { menuOpen?: boolean
}
}, []);
return (
- <>
+
-
- >
+
+
);
}
- let root = ReactDOM.createRoot(element);
+ let root = ReactDOM.createRoot(shadowRoot);
root.render(
-
-
-
-
- ,
+
+
+ ,
);
return promise;
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/state.ts b/src/state.ts
index 4eefd0a..fee50b2 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -1,5 +1,4 @@
import type { ImageLayer, MultiscaleImageLayer } from "@hms-dbmi/viv";
-import type { PrimitiveAtom, WritableAtom } from "jotai";
import { atom } from "jotai";
import { atomFamily, splitAtom, waitForAll } from "jotai/utils";
import type { Matrix4 } from "math.gl";
@@ -14,22 +13,6 @@ export interface ViewState {
target: [number, number];
}
-export function atomWithEffect = void>(
- baseAtom: WritableAtom Update), Result>,
- callback: (data: Update) => void,
-) {
- const derivedAtom: typeof baseAtom = atom(
- (get) => get(baseAtom),
- (get, set, update) => {
- const next = typeof update === "function" ? update(get(baseAtom)) : update;
- const result = set(baseAtom, next);
- callback(next);
- return result;
- },
- );
- return derivedAtom;
-}
-
interface BaseConfig {
source: string | Readable;
axis_labels?: string[];
@@ -123,11 +106,6 @@ export type LayerState = T & { id: string };
-export type ControllerProps = {
- sourceAtom: PrimitiveAtom>;
- layerAtom: PrimitiveAtom>;
-} & T;
-
export const sourceInfoAtom = atom[]>([]);
export const addImageAtom = atom(null, async (get, set, config: ImageLayerConfig) => {
diff --git a/src/theme.ts b/src/theme.ts
deleted file mode 100644
index 632ef1c..0000000
--- a/src/theme.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import grey from "@material-ui/core/colors/grey";
-import { createTheme } from "@material-ui/core/styles";
-
-export default createTheme({
- palette: {
- type: "dark",
- primary: grey,
- secondary: grey,
- },
- props: {
- MuiButton: {
- size: "small",
- },
- MuiButtonBase: {
- disableRipple: true,
- },
- MuiFilledInput: {
- margin: "dense",
- },
- MuiFormControl: {
- margin: "dense",
- },
- MuiFormHelperText: {
- margin: "dense",
- },
- MuiIconButton: {
- size: "small",
- },
- MuiInputBase: {
- margin: "dense",
- },
- MuiInputLabel: {
- margin: "dense",
- },
- MuiOutlinedInput: {
- margin: "dense",
- },
- },
- overrides: {
- MuiSlider: {
- thumb: {
- "&:focus, &:hover": {
- boxShadow: "none",
- },
- height: 11,
- width: 5,
- borderRadius: "15%",
- marginLeft: -1,
- },
- },
- MuiInput: {
- underline: {
- "&&&&:hover:before": {
- borderBottom: "1px solid #fff",
- },
- },
- },
- MuiPaper: {
- root: {
- backgroundColor: "rgba(0, 0, 0, 0.8)",
- },
- },
- MuiSvgIcon: {
- root: {
- width: "0.7em",
- height: "0.7em",
- },
- },
- },
-});
diff --git a/src/utils.ts b/src/utils.ts
index 7d8e983..069f67e 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -425,3 +425,10 @@ export function isOmeroMultiscales(
export function isMultiscales(attrs: zarr.Attributes): attrs is { multiscales: Ome.Multiscale[] } {
return "multiscales" in attrs;
}
+
+export function clamp(
+ value: number,
+ { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY }: { min?: number; max?: number },
+) {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/tsconfig.json b/tsconfig.json
index e376c89..e2a6c71 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,8 +9,10 @@
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
+ "baseUrl": ".",
"paths": {
- "*": ["./node_modules/@danmarshall/deckgl-typings/*"]
+ "*": ["./node_modules/@danmarshall/deckgl-typings/*"],
+ "@/*": ["./src/*"]
},
"types": ["vite/client"]
}
diff --git a/vite.config.js b/vite.config.js
index eefdb22..59443a7 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,5 +1,6 @@
import * as path from "node:path";
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
@@ -9,26 +10,35 @@ const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/z
* Writes a new entry point that exports contents of an existing chunk.
* @param {string} entryPointName - Name of the new entry point
* @param {RegExp} chunkName - Name of the existing chunk
+ * @return {import("vite").Plugin}
*/
function writeEntryPoint(entryPointName, chunkName) {
+ const jsFile = `${entryPointName}.js`;
+ const cssFile = `${entryPointName}.css`;
return {
name: "write-entry-point",
async generateBundle(_, bundle) {
- const chunk = Object.keys(bundle).find((key) => key.match(chunkName));
- if (!chunk) {
+ const chunk = Object.values(bundle).find((key) => key.type === "chunk" && key.fileName.match(chunkName));
+ const styles = Object.values(bundle).find((key) => key.type === "asset" && key.fileName.match(chunkName));
+ if (!chunk || !styles) {
throw new Error(`Could not find chunk matching ${chunkName}`);
}
- bundle[entryPointName] = {
- fileName: entryPointName,
+ bundle[jsFile] = {
+ fileName: jsFile,
type: "chunk",
- code: `export * from './${chunk}';`,
+ code: `export * from './${chunk.fileName}';`,
+ };
+ bundle[cssFile] = {
+ fileName: cssFile,
+ type: "asset",
+ source: styles.source,
};
},
};
}
export default defineConfig({
- plugins: [react(), writeEntryPoint("index.js", /^vizarr-/)],
+ plugins: [react(), tailwindcss(), writeEntryPoint("index", /^vizarr-/)],
base: process.env.VIZARR_PREFIX || "./",
build: {
assetsDir: "",
@@ -37,11 +47,16 @@ export default defineConfig({
output: {
minifyInternalExports: false,
manualChunks: {
- vizarr: [path.resolve(__dirname, "src/index.tsx")],
+ vizarr: [path.resolve(__dirname, "./src/index.tsx")],
},
},
},
},
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
server: {
open: `?source=${source}`,
},