Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type _TileLoadProps as TileLoadProps, TileLayer } from "@deck.gl/geo-la
import { PolygonLayer } from "@deck.gl/layers";
import { type Table } from "apache-arrow";

import { additiveBlendParameters } from "./additiveBlending.glsl";
import { AdditivePolygonLayer } from "./AdditivePolygonLayer";
import { AdditiveScatterplotLayer } from "./AdditiveScatterplotLayer";
import { getPolygon } from "./getPolygon";
Expand Down Expand Up @@ -192,8 +193,11 @@ export const OverlaysLayer = ({
},
};

// Explicit: the TileLayer's defaulted `parameters: {}` would clobber a
// subclass defaultProps value through the `...props` spread.
return new AdditiveScatterplotLayer({
...props,
parameters: additiveBlendParameters,
data,
getRadius: pointRadius,
radiusMinPixels: pointRadiusMin,
Expand Down Expand Up @@ -254,6 +258,7 @@ export const OverlaysLayer = ({
const fillLayer = new AdditivePolygonLayer({
...props,
id: `${props.id}-fill`,
parameters: additiveBlendParameters,
data: arrowTable,
getPolygon: (_d: unknown, context: AccessorContext<unknown>) =>
polygonAccessor(context.index, context),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { vi } from "vitest";

import type { CellMarker } from "../../../../state/store/types";
import { additiveBlendParameters } from "../additiveBlending.glsl";
import { createMarkerProps } from "../markerUniforms";
import { OverlaysLayer } from "../OverlaysLayer";

// deck.gl's TileLayer constructor pulls in the whole runtime (luma.gl, gl
// matrices, etc.) which happy-dom can't handle. We only care about the
// props object the layer was constructed with, so stub TileLayer to a
// minimal class that captures the props verbatim.
vi.mock("@deck.gl/geo-layers", () => ({
TileLayer: class {
props: Record<string, unknown>;
constructor(props: Record<string, unknown>) {
this.props = props;
}
},
}));

vi.mock("@deck.gl/layers", () => ({
PolygonLayer: class {
constructor(public props: Record<string, unknown>) {}
},
}));

vi.mock("../AdditiveScatterplotLayer", () => ({
AdditiveScatterplotLayer: class {
constructor(public props: Record<string, unknown>) {}
},
}));

vi.mock("../AdditivePolygonLayer", () => ({
AdditivePolygonLayer: class {
constructor(public props: Record<string, unknown>) {}
},
}));

vi.mock("~/toast-bridge", () => ({
toastBridge: { emit: vi.fn() },
}));

const makeFileMarkers = (): Record<string, CellMarker> => ({
marker_positive_CD3: {
color: [255, 0, 0, 1],
count: 0,
isVisible: true,
},
});

const buildLayer = () => {
const fileMarkers = makeFileMarkers();
const layer = OverlaysLayer({
resourceId: "res-1",
overlayConfig: null,
fileMarkers,
enabledMarkers: ["marker_positive_CD3"],
markerProps: createMarkerProps(fileMarkers, 0.5),
imageWidth: 1024,
imageHeight: 1024,
minZoom: 0,
maxZoom: 8,
strokeOpacity: 1,
loadTile: vi.fn(),
finishTile: vi.fn(),
});

return layer as unknown as {
props: { renderSubLayers: (props: Record<string, unknown>) => unknown };
};
};

const emptyArrowTable = { numRows: 0, getChild: () => ({ data: [] }) };

describe("OverlaysLayer additive blending", () => {
test("fill sublayers receive the additive blend parameters (parent props must not clobber them)", () => {
const layer = buildLayer();

// Polygon mode (z >= -2): [fill, stroke].
const [fill] = layer.props.renderSubLayers({
id: "MarkersLayer-res-1-tile",
data: emptyArrowTable,
tile: { index: { z: 0, x: 0, y: 0 } },
}) as { props: { parameters: unknown } }[];

expect(fill.props.parameters).toEqual(additiveBlendParameters);
});

test("point sublayers receive the additive blend parameters", () => {
const layer = buildLayer();

// Point mode (z < -2): single AdditiveScatterplotLayer.
const result = layer.props.renderSubLayers({
id: "MarkersLayer-res-1-tile",
data: emptyArrowTable,
tile: { index: { z: -3, x: 0, y: 0 } },
}) as { props: { parameters: unknown } };
const points = Array.isArray(result) ? result[0] : result;

expect(points.props.parameters).toEqual(additiveBlendParameters);
});

test("the additive parameters accumulate source color (src-alpha onto one)", () => {
expect(additiveBlendParameters).toMatchObject({
blend: true,
blendColorOperation: "add",
blendColorSrcFactor: "src-alpha",
blendColorDstFactor: "one",
blendAlphaOperation: "add",
blendAlphaSrcFactor: "one",
blendAlphaDstFactor: "one",
});
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
/** Fragments accumulate onto the framebuffer instead of alpha-blending over it,
* so fills from separate overlay files add up (yellow + blue → white). */
export const additiveBlendParameters = {
blend: true,
blendColorOperation: "add",
blendColorSrcFactor: "src-alpha",
blendColorDstFactor: "one",
blendAlphaOperation: "add",
blendAlphaSrcFactor: "one",
blendAlphaDstFactor: "one",
} as const;

export const shadersInject = {
// Vertex shader: declare and pass the marker bitmask
"vs:#decl": /* glsl */ `
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type RGBA,
} from "../../../state/store/types";
import { useTilesLoading } from "../../../utils/useTilesLoading";
import { DEFAULT_OVERLAYS_FILL_OPACITY } from "~/utils/overlayDefaults";

/** Creates the overlay marker layers for one image panel. */
export const useOverlaysLayers = (imagePanelId: number): CytarioLayerResult => {
Expand All @@ -29,7 +30,8 @@ export const useOverlaysLayers = (imagePanelId: number): CytarioLayerResult => {
const imageWidth = metadata?.Pixels?.SizeX ?? 0;
const imageHeight = metadata?.Pixels?.SizeY ?? 0;

const fillOpacity = layersStates[panelLayersStateIndex]?.overlaysFillOpacity ?? 0.8;
const fillOpacity =
layersStates[panelLayersStateIndex]?.overlaysFillOpacity ?? DEFAULT_OVERLAYS_FILL_OPACITY;
const showCellOutline = layersStates[panelLayersStateIndex]?.showCellOutline ?? true;

const overlayState = layersStates[panelLayersStateIndex]?.overlays ?? null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Banner, Button, Input, Select, type SelectItem } from "@cytario/design";
import { useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";

import { getOverlayState, markerDisplayLabel } from "./getOverlayState";
import { useViewerStore } from "../../../state/store/core/ViewerStoreContext";
import { getOverlayState, markerDisplayLabel, paletteOffsetFor } from "./getOverlayState";
import { ViewerStoreContext, useViewerStore } from "../../../state/store/core/ViewerStoreContext";
import { applyOverlayReconfiguration } from "../../../state/store/overlays/overlays.store";
import { select } from "../../../state/store/selectors";
import { type OverlayConfig, type OverlayEntry } from "../../../state/store/types";
Expand Down Expand Up @@ -85,6 +85,7 @@ export function OverlayConfigModal({
onClose,
onApplyError,
}: OverlayConfigModalProps) {
const viewerStore = useContext(ViewerStoreContext);
const updateOverlayConfig = useViewerStore(select.updateOverlayConfig);

const [schema, setSchema] = useState<ParquetColumn[] | null>(null);
Expand Down Expand Up @@ -177,7 +178,12 @@ export function OverlayConfigModal({
}
try {
const markerInfo = await getMarkerInfoWasm(resourceId, nextConfig);
const markers = getOverlayState(markerInfo, nextConfig);
const overlaysStates = viewerStore ? select.overlaysStates(viewerStore.getState()) : {};
const markers = getOverlayState(
markerInfo,
nextConfig,
paletteOffsetFor(overlaysStates, resourceId),
);
updateOverlayConfig(resourceId, nextConfig, markers);
applyOverlayReconfiguration(resourceId);
onClose();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Badge, Banner, Link, MenuItem, Switch, Tooltip, useToast } from "@cytario/design";
import { useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";

import { getOverlayState } from "./getOverlayState";
import { getOverlayState, paletteOffsetFor } from "./getOverlayState";
import { OverlayConfigModal } from "./OverlayConfig.modal";
import { useViewerStore } from "../../../state/store/core/ViewerStoreContext";
import { useViewerStore, ViewerStoreContext } from "../../../state/store/core/ViewerStoreContext";
import { select } from "../../../state/store/selectors";
import { type OverlayEntry, RGBA } from "../../../state/store/types";
import { AccordionToggle } from "../AccordionToggle";
Expand All @@ -26,6 +26,7 @@ interface OverlayItemProps {
}

export const OverlayItem = ({ resourceId, overlay }: OverlayItemProps) => {
const viewerStore = useContext(ViewerStoreContext);
const setMarkerVisibility = useViewerStore(select.setMarkerVisibility);
const setMarkerColor = useViewerStore(select.setMarkerColor);
const removeOverlaysState = useViewerStore(select.removeOverlaysState);
Expand Down Expand Up @@ -83,7 +84,12 @@ export const OverlayItem = ({ resourceId, overlay }: OverlayItemProps) => {
try {
const markerInfo = await getMarkerInfoWasm(resourceId, overlay.config);
if (markerInfo && Object.keys(markerInfo).length > 0) {
const newOverlayState = getOverlayState(markerInfo, overlay.config);
const overlaysStates = viewerStore ? select.overlaysStates(viewerStore.getState()) : {};
const newOverlayState = getOverlayState(
markerInfo,
overlay.config,
paletteOffsetFor(overlaysStates, resourceId),
);
updateOverlaysState(resourceId, newOverlayState);
} else {
toast({
Expand Down Expand Up @@ -111,6 +117,7 @@ export const OverlayItem = ({ resourceId, overlay }: OverlayItemProps) => {
toast,
fileName,
overlay.config,
viewerStore,
]);

// Total cell/object count for the file-level badge (rows in the parquet).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { OverlayConfigModal } from "../OverlayConfig.modal";
import { getParquetTopLevelSchema } from "~/components/DataGrid/getParquetSchema";
import { getMarkerInfoWasm } from "~/utils/db/getMarkerInfoWasm";

vi.mock("../../../../state/store/core/ViewerStoreContext", () => ({
useViewerStore: vi.fn(),
}));
vi.mock("../../../../state/store/core/ViewerStoreContext", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../state/store/core/ViewerStoreContext")>();
return { ...actual, useViewerStore: vi.fn() };
});

vi.mock("~/components/DataGrid/getParquetSchema", () => ({
getParquetSchema: vi.fn(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getOverlayState, markerDisplayLabel } from "../getOverlayState";
import type { OverlaysState } from "../../../../state/store/types";
import { CATEGORICAL_COLORS } from "../../SectionRow/ColorPicker/utils";
import { getOverlayState, markerDisplayLabel, paletteOffsetFor } from "../getOverlayState";
import type { OverlayConfig } from "~/utils/db/overlayConfig";

const config: OverlayConfig = {
Expand Down Expand Up @@ -49,3 +51,47 @@ describe("getOverlayState with config", () => {
expect(state["marker_positive_cd4"].label).toBe("cd4");
});
});

describe("palette offset across overlays", () => {
const makeMarker = () => ({
color: [0, 0, 0, 1] as [number, number, number, number],
count: 0,
isVisible: false,
label: "",
});

const makeOverlays = (markerCounts: Record<string, number>) =>
Object.fromEntries(
Object.entries(markerCounts).map(([id, markerCount]) => [
id,
{
markers: Object.fromEntries(
Array.from({ length: markerCount }, (_, i) => [`marker_${i}`, makeMarker()]),
),
config: null,
},
]),
) as unknown as OverlaysState;

test("counts only markers from other overlays", () => {
const overlays = makeOverlays({ "res-a": 3, "res-b": 2, "res-c": 4 });

expect(paletteOffsetFor(overlays, "res-b")).toBe(7);
expect(paletteOffsetFor(overlays, "res-a")).toBe(6);
expect(paletteOffsetFor({}, "res-a")).toBe(0);
});

test("offset shifts the palette so same-named markers land on distinct colors", () => {
const first = getOverlayState({ marker_positive_ck: { count: 1 } });
const second = getOverlayState({ marker_positive_ck: { count: 1 } }, null, 1);

expect(first["marker_positive_ck"].color).toEqual(CATEGORICAL_COLORS[0]);
expect(second["marker_positive_ck"].color).toEqual(CATEGORICAL_COLORS[1]);
});

test("offset wraps around the palette", () => {
const state = getOverlayState({ a: { count: 1 } }, null, CATEGORICAL_COLORS.length + 1);

expect(state["a"].color).toEqual(CATEGORICAL_COLORS[1]);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OverlayState, RGBA } from "../../../state/store/types";
import { OverlaysState, OverlayState, RGBA } from "../../../state/store/types";
import { CATEGORICAL_COLORS } from "../SectionRow/ColorPicker/utils";
import { type OverlayConfig, MARKER_POSITIVE_PREFIX } from "~/utils/db/overlayConfig";

Expand All @@ -8,6 +8,14 @@ export type MarkerInfo = Record<string, { count: number }>;
const categoricalColorAt = (index: number): RGBA =>
CATEGORICAL_COLORS[index % CATEGORICAL_COLORS.length];

/** Markers held by overlays other than {@link resourceId}, shifted past so the
* same marker loaded from two files starts life on distinct palette colors. */
export function paletteOffsetFor(overlays: OverlaysState, resourceId: string): number {
return Object.entries(overlays)
.filter(([id]) => id !== resourceId)
.reduce((offset, [, entry]) => offset + Object.keys(entry.markers).length, 0);
}

/** Display label for a marker: config label wins, else the prefix-stripped key. */
export function markerDisplayLabel(markerKey: string, config?: OverlayConfig | null): string {
const cls = config?.classes.find((c) => c.sourceColumn === markerKey);
Expand All @@ -20,11 +28,12 @@ export function markerDisplayLabel(markerKey: string, config?: OverlayConfig | n
export function getOverlayState(
markerInfo: MarkerInfo,
config?: OverlayConfig | null,
paletteOffset = 0,
): OverlayState {
const columnNames = Object.keys(markerInfo);
const overlayState = columnNames.reduce((acc, name, index) => {
acc[name] = {
color: categoricalColorAt(index),
color: categoricalColorAt(index + paletteOffset),
count: markerInfo[name].count ?? 0,
isVisible: false,
label: markerDisplayLabel(name, config),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
LayerChannelsState,
ViewerStore,
} from "./types";
import { DEFAULT_OVERLAYS_FILL_OPACITY } from "~/utils/overlayDefaults";

const EMPTY_OBJECT = Object.freeze({});

Expand Down Expand Up @@ -244,7 +245,7 @@ export const select = {

overlaysFillOpacity: (state: ViewerStore) => {
const layerState = getLayersState(state);
return layerState?.overlaysFillOpacity ?? 0.8;
return layerState?.overlaysFillOpacity ?? DEFAULT_OVERLAYS_FILL_OPACITY;
},
setOverlaysFillOpacity: (state: ViewerStore) => state.setOverlaysFillOpacity,

Expand Down
3 changes: 2 additions & 1 deletion app/components/.client/ImageViewer/state/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ViewSlice } from "./core/viewport.store";
import type { OverlaysSlice } from "./overlays/overlays.store";
import type { ViewsSlice } from "./views/views.store";
import type { OverlayConfig } from "~/utils/db/overlayConfig";
import { DEFAULT_OVERLAYS_FILL_OPACITY } from "~/utils/overlayDefaults";

export type { OverlayConfig };

Expand Down Expand Up @@ -84,7 +85,7 @@ export const createDefaultLayersStateEntry = (author = ""): LayersStateEntry =>
channels: {},
overlays: {},
channelsOpacity: 1,
overlaysFillOpacity: 0.5,
overlaysFillOpacity: DEFAULT_OVERLAYS_FILL_OPACITY,
showCellOutline: true,
annotationsOpacity: 0.5,
showAnnotationOutline: true,
Expand Down
Loading
Loading