diff --git a/app/components/.client/ImageViewer/components/canvas/Overlays/OverlaysLayer.tsx b/app/components/.client/ImageViewer/components/canvas/Overlays/OverlaysLayer.tsx index 37b49c82..52f96ca9 100644 --- a/app/components/.client/ImageViewer/components/canvas/Overlays/OverlaysLayer.tsx +++ b/app/components/.client/ImageViewer/components/canvas/Overlays/OverlaysLayer.tsx @@ -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"; @@ -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, @@ -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) => polygonAccessor(context.index, context), diff --git a/app/components/.client/ImageViewer/components/canvas/Overlays/__tests__/additiveBlending.test.ts b/app/components/.client/ImageViewer/components/canvas/Overlays/__tests__/additiveBlending.test.ts new file mode 100644 index 00000000..76e9111d --- /dev/null +++ b/app/components/.client/ImageViewer/components/canvas/Overlays/__tests__/additiveBlending.test.ts @@ -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; + constructor(props: Record) { + this.props = props; + } + }, +})); + +vi.mock("@deck.gl/layers", () => ({ + PolygonLayer: class { + constructor(public props: Record) {} + }, +})); + +vi.mock("../AdditiveScatterplotLayer", () => ({ + AdditiveScatterplotLayer: class { + constructor(public props: Record) {} + }, +})); + +vi.mock("../AdditivePolygonLayer", () => ({ + AdditivePolygonLayer: class { + constructor(public props: Record) {} + }, +})); + +vi.mock("~/toast-bridge", () => ({ + toastBridge: { emit: vi.fn() }, +})); + +const makeFileMarkers = (): Record => ({ + 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) => 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", + }); + }); +}); diff --git a/app/components/.client/ImageViewer/components/canvas/Overlays/additiveBlending.glsl.ts b/app/components/.client/ImageViewer/components/canvas/Overlays/additiveBlending.glsl.ts index 4fe87f14..0fc358c8 100644 --- a/app/components/.client/ImageViewer/components/canvas/Overlays/additiveBlending.glsl.ts +++ b/app/components/.client/ImageViewer/components/canvas/Overlays/additiveBlending.glsl.ts @@ -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 */ ` diff --git a/app/components/.client/ImageViewer/components/canvas/Overlays/useOverlaysLayer.tsx b/app/components/.client/ImageViewer/components/canvas/Overlays/useOverlaysLayer.tsx index e16208b6..61b51bb5 100644 --- a/app/components/.client/ImageViewer/components/canvas/Overlays/useOverlaysLayer.tsx +++ b/app/components/.client/ImageViewer/components/canvas/Overlays/useOverlaysLayer.tsx @@ -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 => { @@ -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; diff --git a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayConfig.modal.tsx b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayConfig.modal.tsx index f76ccfc4..a98e617c 100644 --- a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayConfig.modal.tsx +++ b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayConfig.modal.tsx @@ -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"; @@ -85,6 +85,7 @@ export function OverlayConfigModal({ onClose, onApplyError, }: OverlayConfigModalProps) { + const viewerStore = useContext(ViewerStoreContext); const updateOverlayConfig = useViewerStore(select.updateOverlayConfig); const [schema, setSchema] = useState(null); @@ -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(); diff --git a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayItem.tsx b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayItem.tsx index eb93e6d7..7beadbca 100644 --- a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayItem.tsx +++ b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/OverlayItem.tsx @@ -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"; @@ -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); @@ -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({ @@ -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). diff --git a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/OverlayConfig.modal.test.tsx b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/OverlayConfig.modal.test.tsx index a82d6e82..367f7406 100644 --- a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/OverlayConfig.modal.test.tsx +++ b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/OverlayConfig.modal.test.tsx @@ -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(); + return { ...actual, useViewerStore: vi.fn() }; +}); vi.mock("~/components/DataGrid/getParquetSchema", () => ({ getParquetSchema: vi.fn(), diff --git a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/getOverlayState.test.ts b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/getOverlayState.test.ts index ef491017..4e49340d 100644 --- a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/getOverlayState.test.ts +++ b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/__tests__/getOverlayState.test.ts @@ -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 = { @@ -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) => + 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]); + }); +}); diff --git a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/getOverlayState.ts b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/getOverlayState.ts index f2f7fa65..5d327b20 100644 --- a/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/getOverlayState.ts +++ b/app/components/.client/ImageViewer/components/sidebar/OverlaysSection/getOverlayState.ts @@ -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"; @@ -8,6 +8,14 @@ export type MarkerInfo = Record; 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); @@ -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), diff --git a/app/components/.client/ImageViewer/state/store/selectors.ts b/app/components/.client/ImageViewer/state/store/selectors.ts index 6d95a136..420936a3 100644 --- a/app/components/.client/ImageViewer/state/store/selectors.ts +++ b/app/components/.client/ImageViewer/state/store/selectors.ts @@ -9,6 +9,7 @@ import { LayerChannelsState, ViewerStore, } from "./types"; +import { DEFAULT_OVERLAYS_FILL_OPACITY } from "~/utils/overlayDefaults"; const EMPTY_OBJECT = Object.freeze({}); @@ -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, diff --git a/app/components/.client/ImageViewer/state/store/types.ts b/app/components/.client/ImageViewer/state/store/types.ts index 63309f50..67255c1c 100644 --- a/app/components/.client/ImageViewer/state/store/types.ts +++ b/app/components/.client/ImageViewer/state/store/types.ts @@ -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 }; @@ -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, diff --git a/app/utils/__tests__/overlayDefaults.test.ts b/app/utils/__tests__/overlayDefaults.test.ts new file mode 100644 index 00000000..01f8e9c4 --- /dev/null +++ b/app/utils/__tests__/overlayDefaults.test.ts @@ -0,0 +1,31 @@ +import { viewSettingsDocumentSchema } from "../db/viewSettingsSchema"; +import { DEFAULT_OVERLAYS_FILL_OPACITY } from "../overlayDefaults"; +import { select } from "~/components/.client/ImageViewer/state/store/selectors"; +import { createDefaultLayersStateEntry } from "~/components/.client/ImageViewer/state/store/types"; +import type { ViewerStore } from "~/components/.client/ImageViewer/state/store/types"; + +describe("DEFAULT_OVERLAYS_FILL_OPACITY", () => { + test("is semi-transparent", () => { + expect(DEFAULT_OVERLAYS_FILL_OPACITY).toBeGreaterThan(0); + expect(DEFAULT_OVERLAYS_FILL_OPACITY).toBeLessThan(1); + }); + + test("seeds a new layers state entry", () => { + expect(createDefaultLayersStateEntry().overlaysFillOpacity).toBe(DEFAULT_OVERLAYS_FILL_OPACITY); + }); + + test("applies to sidecar view entries that omit the field", () => { + const document = viewSettingsDocumentSchema.parse({ + cytario: { schemaVersion: "1.1", kind: "settings", image: "img.ome.tiff", author: "a" }, + views: [{ id: "view-1", author: "a", channels: {} }], + }); + expect(document.views[0].overlaysFillOpacity).toBe(DEFAULT_OVERLAYS_FILL_OPACITY); + }); + + test("is the selector fallback when no layers state exists", () => { + const emptyStore = { imagePanels: [0], imagePanelIndex: 0, layersStates: [] }; + expect(select.overlaysFillOpacity(emptyStore as unknown as ViewerStore)).toBe( + DEFAULT_OVERLAYS_FILL_OPACITY, + ); + }); +}); diff --git a/app/utils/db/viewSettingsSchema.ts b/app/utils/db/viewSettingsSchema.ts index 5f0fd2e0..51f8d138 100644 --- a/app/utils/db/viewSettingsSchema.ts +++ b/app/utils/db/viewSettingsSchema.ts @@ -5,6 +5,7 @@ import type { LayersStateEntry, RGB, } from "~/components/.client/ImageViewer/state/store/types"; +import { DEFAULT_OVERLAYS_FILL_OPACITY } from "~/utils/overlayDefaults"; const SCHEMA_VERSION = "1.1"; @@ -70,7 +71,7 @@ export const viewSettingsChannelSchema = z.object({ ), channelsOpacity: z.number().default(1), overlays: sidecarOverlaysSchema.default({}), - overlaysFillOpacity: z.number().default(0.8), + overlaysFillOpacity: z.number().default(DEFAULT_OVERLAYS_FILL_OPACITY), showCellOutline: z.boolean().default(true), annotationsOpacity: z.number().default(1), showAnnotationOutline: z.boolean().default(true), diff --git a/app/utils/overlayDefaults.ts b/app/utils/overlayDefaults.ts new file mode 100644 index 00000000..a7ff34fe --- /dev/null +++ b/app/utils/overlayDefaults.ts @@ -0,0 +1,2 @@ +/** Semi-transparent so fills from separate overlay sets stay distinguishable where they overlap. */ +export const DEFAULT_OVERLAYS_FILL_OPACITY = 0.5;