Skip to content

Commit 69e2e7d

Browse files
authored
feat(ngff): Support displaying "labels" for "multiscales" nodes (#242)
* Support OME "labels" metadata * Prune unused palettes * Remove unused ability to assert func * Set default label opacity to 0.5 * Support independent selection transforms for labels * Remove unused types * Clean up layer state logic * Add layer toggle with default off
1 parent 55b0d0f commit 69e2e7d

File tree

11 files changed

+596
-43
lines changed

11 files changed

+596
-43
lines changed

main.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ async function main() {
2020

2121
// Add event listener to sync viewState as query param.
2222
// Debounce to limit how quickly we are pushing to browser history
23-
viewer.on(
24-
"viewStateChange",
25-
debounce((update: vizarr.ViewState) => {
26-
const url = new URL(window.location.href);
27-
url.searchParams.set("viewState", JSON.stringify(update));
28-
window.history.pushState({}, "", decodeURIComponent(url.href));
29-
}, 200),
30-
);
23+
// viewer.on(
24+
// "viewStateChange",
25+
// debounce((update: vizarr.ViewState) => {
26+
// const url = new URL(window.location.href);
27+
// url.searchParams.set("viewState", JSON.stringify(update));
28+
// window.history.pushState({}, "", decodeURIComponent(url.href));
29+
// }, 200),
30+
// );
3131

3232
// parse image config
3333
// @ts-expect-error - TODO: validate config

src/components/LayerController/Content.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import AcquisitionController from "./AcquisitionController";
66
import AddChannelButton from "./AddChannelButton";
77
import AxisSliders from "./AxisSliders";
88
import ChannelController from "./ChannelController";
9+
import Labels from "./Labels";
910
import OpacitySlider from "./OpacitySlider";
1011

1112
import { useLayerState } from "../../hooks";
@@ -51,6 +52,19 @@ function Content() {
5152
<ChannelController channelIndex={i} key={i} />
5253
))}
5354
</Grid>
55+
{layer.labels?.length && (
56+
<>
57+
<Grid container justifyContent="space-between">
58+
<Typography variant="caption">labels:</Typography>
59+
</Grid>
60+
<Divider />
61+
<Grid>
62+
{layer.labels.map((label, i) => (
63+
<Labels labelIndex={i} key={label.layerProps.id} />
64+
))}
65+
</Grid>
66+
</>
67+
)}
5468
</Grid>
5569
</Details>
5670
);
+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Grid, IconButton, Slider, Typography } from "@material-ui/core";
2+
import { RadioButtonChecked, RadioButtonUnchecked } from "@material-ui/icons";
3+
import React from "react";
4+
5+
import { useLayerState, useSourceData } from "../../hooks";
6+
import { assert } from "../../utils";
7+
8+
export default function Labels({ labelIndex }: { labelIndex: number }) {
9+
const [source] = useSourceData();
10+
const [layer, setLayer] = useLayerState();
11+
assert(source.labels && layer.kind === "multiscale" && layer.labels, "Missing image labels");
12+
13+
const handleOpacityChange = (_: unknown, value: number | number[]) => {
14+
setLayer((prev) => {
15+
assert(prev.kind === "multiscale" && prev.labels, "Missing image labels");
16+
return {
17+
...prev,
18+
labels: prev.labels.with(labelIndex, {
19+
...prev.labels[labelIndex],
20+
layerProps: {
21+
...prev.labels[labelIndex].layerProps,
22+
opacity: value as number,
23+
},
24+
}),
25+
};
26+
});
27+
};
28+
29+
const { name } = source.labels[labelIndex];
30+
const label = layer.labels[labelIndex];
31+
return (
32+
<>
33+
<Grid container justifyContent="space-between" wrap="nowrap">
34+
<div style={{ width: 165, overflow: "hidden", textOverflow: "ellipsis" }}>
35+
<Typography variant="caption" noWrap>
36+
{name}
37+
</Typography>
38+
</div>
39+
</Grid>
40+
<Grid container justifyContent="space-between">
41+
<Grid item xs={2}>
42+
<IconButton
43+
style={{ backgroundColor: "transparent", padding: 0, zIndex: 2 }}
44+
onClick={() => {
45+
setLayer((prev) => {
46+
assert(prev.kind === "multiscale" && prev.labels, "Missing image labels");
47+
return {
48+
...prev,
49+
labels: prev.labels.with(labelIndex, {
50+
...prev.labels[labelIndex],
51+
on: !prev.labels[labelIndex].on,
52+
}),
53+
};
54+
});
55+
}}
56+
>
57+
{label.on ? <RadioButtonChecked /> : <RadioButtonUnchecked />}
58+
</IconButton>
59+
</Grid>
60+
<Grid item xs={10}>
61+
<Slider
62+
value={label.layerProps.opacity}
63+
onChange={handleOpacityChange}
64+
min={0}
65+
max={1}
66+
step={0.01}
67+
style={{ padding: "10px 0px 5px 0px" }}
68+
/>
69+
</Grid>
70+
</Grid>
71+
</>
72+
);
73+
}

src/components/LayerController/index.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ const Accordion = withStyles({
3131
function LayerController() {
3232
const [sourceInfo] = useSourceData();
3333
const layerAtom = layerFamilyAtom(sourceInfo);
34-
const { name = "" } = sourceInfo;
3534
return (
3635
<LayerStateContext.Provider value={layerAtom}>
3736
<Accordion defaultExpanded>

src/io.ts

+47
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ZarrPixelSource } from "./ZarrPixelSource";
33
import { loadOmeMultiscales, loadPlate, loadWell } from "./ome";
44
import * as utils from "./utils";
55

6+
import { DEFAULT_LABEL_OPACITY } from "./layers/label-layer";
67
import type { BaseLayerProps } from "./layers/viv-layers";
78
import type { ImageLayerConfig, LayerState, MultichannelConfig, SingleChannelConfig, SourceData } from "./state";
89

@@ -218,12 +219,58 @@ export function initLayerStateFromSource(source: SourceData & { id: string }): L
218219
};
219220
}
220221

222+
let labels = undefined;
223+
if (source.labels && source.labels.length > 0) {
224+
labels = source.labels.map((label, i) => ({
225+
on: false,
226+
transformSourceSelection: getSourceSelectionTransform(label.loader[0], source.loader[0]),
227+
layerProps: {
228+
id: `${source.id}_${i}`,
229+
loader: label.loader,
230+
modelMatrix: label.modelMatrix,
231+
opacity: DEFAULT_LABEL_OPACITY,
232+
colors: label.colors,
233+
},
234+
}));
235+
}
236+
221237
return {
222238
kind: "multiscale",
223239
layerProps: {
224240
...layerProps,
225241
loader: source.loader,
226242
},
227243
on: true,
244+
labels,
245+
};
246+
}
247+
248+
function getSourceSelectionTransform(
249+
labels: { shape: Array<number>; labels: Array<string> },
250+
source: { shape: Array<number>; labels: Array<string> },
251+
) {
252+
utils.assert(
253+
source.shape.length === source.labels.length,
254+
`Image source axes and shape are not same rank. Got ${JSON.stringify(source)}`,
255+
);
256+
utils.assert(
257+
labels.shape.length === labels.labels.length,
258+
`Label axes and shape are not same rank. Got ${JSON.stringify(labels)}`,
259+
);
260+
utils.assert(
261+
labels.labels.every((label) => source.labels.includes(label)),
262+
`Label axes MUST be a subset of source. Source: ${JSON.stringify(source.labels)} Labels: ${JSON.stringify(labels.labels)}`,
263+
);
264+
// Identify labels that should always map to 0, regardless of the source selection.
265+
const excludeFromTransformedSelection = new Set(
266+
utils
267+
.zip(labels.labels, labels.shape)
268+
.filter(([_, size]) => size === 1)
269+
.map(([name, _]) => name),
270+
);
271+
return (sourceSelection: Array<number>): Array<number> => {
272+
return labels.labels.map((name) =>
273+
excludeFromTransformedSelection.has(name) ? 0 : sourceSelection[source.labels.indexOf(name)],
274+
);
228275
};
229276
}

0 commit comments

Comments
 (0)