diff --git a/src/lib/data/byod.test.ts b/src/lib/data/byod.test.ts new file mode 100644 index 00000000..2c3a8136 --- /dev/null +++ b/src/lib/data/byod.test.ts @@ -0,0 +1,29 @@ +import { upsertSampleEntry } from '$src/lib/data/byod'; +import type { Sample } from '$src/lib/data/objects/sample'; +import { describe, expect, it } from 'vitest'; + +describe('upsertSampleEntry', () => { + const makeSample = (name: string, marker: string) => ({ + name, + sample: { marker } as unknown as Sample + }); + + it('replaces existing sample entries by name', () => { + const remote = makeSample('demo', 'remote'); + const local = makeSample('demo', 'local'); + + const updated = upsertSampleEntry([remote], local); + expect(updated).toHaveLength(1); + expect(updated[0]).not.toBe(remote); + expect(updated[0].sample).toBe(local.sample); + }); + + it('appends when no matching name exists', () => { + const first = makeSample('a', 'first'); + const second = makeSample('b', 'second'); + + const result = upsertSampleEntry([first], second); + expect(result).toHaveLength(2); + expect(result[1]).toBe(second); + }); +}); diff --git a/src/lib/data/byod.ts b/src/lib/data/byod.ts index 4010803b..2bf60580 100644 --- a/src/lib/data/byod.ts +++ b/src/lib/data/byod.ts @@ -33,6 +33,16 @@ export async function byod() { return processHandle(handle, true); } +type NamedSample = { name: string; sample: Sample }; + +export function upsertSampleEntry(existing: NamedSample[], entry: NamedSample): NamedSample[] { + const idx = existing.findIndex((x) => x.name === entry.name); + if (idx === -1) return [...existing, entry]; + const next = existing.slice(); + next[idx] = entry; + return next; +} + async function processCSV(name: string, text: string) { const res = (await fromCSV(text))?.data; if (!res) { @@ -71,7 +81,7 @@ async function processCSV(name: string, text: string) { export async function processHandle( handle: Promise, setSample = true -) { +): Promise<'folder' | 'file' | undefined> { const h = await handle; if (h instanceof FileSystemFileHandle) { const file = await h.getFile(); @@ -82,12 +92,12 @@ export async function processHandle( proc = JSON.parse(text); } catch (e) { await processCSV(file.name, text); - return; + return 'file'; } if (!proc) { alert('Invalid JSON file'); - return; + return 'file'; } const map = get(sMapp); @@ -101,22 +111,22 @@ export async function processHandle( const roidata = proc; console.log('Got roi feature data'); map.persistentLayers.rois.loadFeatures(roidata); - return; + return 'file'; } if (valAnnFeatData(proc)) { const annfeatdata = proc; console.log('Got annotation feature data'); map.persistentLayers.annotations.loadFeatures(annfeatdata); - return; + return 'file'; } alert('Validation error: ' + JSON.stringify(valROIData.errors)); - return; + return 'file'; } alert('Unknown file type.'); - return; + return 'file'; } return processFolder(h, setSample); @@ -128,7 +138,7 @@ async function processFolder(handle: FileSystemDirectoryHandle, setSample = true sp = (await readFile(handle, 'sample.json', 'plain')) as SampleParams; } catch (e) { alert('Got folder but cannot find sample.json in the specified directory'); - return; + return undefined; } const existing = get(samples); @@ -139,10 +149,10 @@ async function processFolder(handle: FileSystemDirectoryHandle, setSample = true existing.find((x) => x.name === sample.name) && !confirm(`Sample ${sample.name} already exists. Overwrite?`) ) { - return; + return undefined; } - existing.push({ name: sample.name, sample }); - samples.set(existing); + const updated = upsertSampleEntry(existing, { name: sample.name, sample }); + samples.set(updated); if (setSample) { const curr = get(mapIdSample); for (const id of Object.keys(curr)) { @@ -151,4 +161,5 @@ async function processFolder(handle: FileSystemDirectoryHandle, setSample = true mapIdSample.set(curr); console.log('Set sample to', sample.name); } + return 'folder'; } diff --git a/src/lib/data/objects/sample.ts b/src/lib/data/objects/sample.ts index de3eea38..64f16710 100644 --- a/src/lib/data/objects/sample.ts +++ b/src/lib/data/objects/sample.ts @@ -190,4 +190,30 @@ export class Sample extends Deferrable { } return featureList; } + + hasFeature(fn: FeatureAndGroup) { + const dataset = this.features[fn.group]; + if (!dataset) return false; + const names = (dataset as { featNames?: string[] }).featNames; + if (!Array.isArray(names)) return false; + return names.includes(fn.feature); + } + + async firstFeature(): Promise { + const defaults = this.overlayParams?.defaults; + if (defaults) { + for (const entry of defaults) { + if (this.hasFeature(entry)) { + return entry; + } + } + } + const list = await this.genFeatureList(); + for (const { group, features } of list) { + if (features?.length) { + return { group, feature: features[0] }; + } + } + return undefined; + } } diff --git a/src/lib/data/objects/test/sample.test.ts b/src/lib/data/objects/test/sample.test.ts index 4dfd0ee7..b52acda4 100644 --- a/src/lib/data/objects/test/sample.test.ts +++ b/src/lib/data/objects/test/sample.test.ts @@ -206,6 +206,21 @@ describe('Sample', () => { expect(sample.metadataMd).toEqual(sampleParams.metadataMd); }); + it('reports feature availability and returns the first available feature', async () => { + const sample = new Sample(sampleParams); + await sample.hydrate(); + plainCsvInstances[0].featNames = ['geneA', 'geneB']; + chunkedCsvInstances[0].featNames = ['proteinX']; + chunkedCsvInstances[0].names = { proteinX: 0 }; + + expect(sample.hasFeature({ group: 'plain', feature: 'geneA' })).toBe(true); + expect(sample.hasFeature({ group: 'plain', feature: 'missing' })).toBe(false); + expect(sample.hasFeature({ group: 'chunked', feature: 'proteinX' })).toBe(true); + + const first = await sample.firstFeature(); + expect(first).toEqual({ group: 'plain', feature: 'geneA' }); + }); + it('hydrates image and features once', async () => { const handle = { kind: 'dir' } as unknown as FileSystemDirectoryHandle; const sample = new Sample(sampleParams, handle); diff --git a/src/lib/sidebar/recent.browser.test.ts b/src/lib/sidebar/recent.browser.test.ts new file mode 100644 index 00000000..5a164e67 --- /dev/null +++ b/src/lib/sidebar/recent.browser.test.ts @@ -0,0 +1,33 @@ +import { HoverSelect } from '$lib/sidebar/searchBox'; +import { hoverSelect, sSample } from '$lib/store'; +import Recent from '$src/lib/sidebar/recent.svelte'; +import { tick } from 'svelte'; +import { afterEach, describe, expect, test } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +const makeFeature = (name: string) => ({ group: 'grp', feature: name }); + +afterEach(() => { + hoverSelect.set(new HoverSelect()); + sSample.set(undefined as any); +}); + +describe('Recent sidebar', () => { + test('clears queue when sample changes', async () => { + const { getByText, container, unmount } = render(Recent); + + sSample.set({ name: 'sample-a' } as any); + hoverSelect.set(new HoverSelect({ selected: makeFeature('geneA') })); + await tick(); + + expect(getByText('geneA')).toBeTruthy(); + + sSample.set({ name: 'sample-b' } as any); + await tick(); + + expect(container.textContent).not.toContain('geneA'); + expect(container.textContent).toContain('No recent features (yet).'); + + unmount(); + }); +}); diff --git a/src/lib/sidebar/recent.svelte b/src/lib/sidebar/recent.svelte index 558f4f62..cac53094 100644 --- a/src/lib/sidebar/recent.svelte +++ b/src/lib/sidebar/recent.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/ui/background/CompositeChannelTable.svelte b/src/lib/ui/background/CompositeChannelTable.svelte index 8f554d53..cbbcbfa6 100644 --- a/src/lib/ui/background/CompositeChannelTable.svelte +++ b/src/lib/ui/background/CompositeChannelTable.svelte @@ -76,78 +76,80 @@ > {#each image.channels as name, i (i)} - {@const band = controller.variables[name]!} - {@const colorIndex = colors.findIndex((color) => color === band.color)} - - - - - - -
-
- onSelect(name, band.color)} - bind:values={band.minmax} - id={`slider-${name}`} - /> -
- onSelect(name, band.color, true)} + aria-label="Select channel button" > - [{band.minmax.map((value: number) => Math.round(value ** 2))}] - -
- - - {#each zip(colors, bgColors) as [color, bg], index (index)} +
= 0 ? bgColors[colorIndex] : ''} text-white` + : 'opacity-80 hover:opacity-100', + band.enabled && ['white', 'yellow'].includes(band.color) ? 'text-black' : '', + 'rounded-lg px-2 py-[1px] w-fit max-w-[100px]' + )} + > +
{name}
+
+ - {/each} - - + + +
+
+ onSelect(name, band.color)} + bind:values={band.minmax} + id={`slider-${name}`} + /> +
+ + [{band.minmax.map((value: number) => Math.round(value ** 2))}] + +
+ + + {#each zip(colors, bgColors) as [color, bg], index (index)} + + {/each} + + + {/if} {/each} diff --git a/src/lib/ui/background/imgControl.browser.test.ts b/src/lib/ui/background/imgControl.browser.test.ts index 83b602ef..187e3537 100644 --- a/src/lib/ui/background/imgControl.browser.test.ts +++ b/src/lib/ui/background/imgControl.browser.test.ts @@ -1,8 +1,10 @@ import { sEvent } from '$lib/store'; import { ImgData, type ImageParams } from '$src/lib/data/objects/image'; +import CompositeChannelTable from '$src/lib/ui/background/CompositeChannelTable.svelte'; import { Background } from '$src/lib/ui/background/imgBackground'; import type { BandInfo, CompCtrl, ImgCtrl, RGBCtrl } from '$src/lib/ui/background/imgColormap'; import ImgControl from '$src/lib/ui/background/imgControl.svelte'; +import { normalizeCompositeController } from '$src/lib/ui/background/imgControlState'; import { fireEvent } from '@testing-library/svelte'; import { userEvent } from '@vitest/browser/context'; import { beforeEach, expect, test, vi } from 'vitest'; @@ -196,6 +198,39 @@ test('rgb controls propagate slider changes to background style updates', async screen.unmount(); }); +test('composite channel table handles missing controller entries', async () => { + const image = { + channels: ['alpha', 'beta'], + maxVal: 255, + defaultChannels: {}, + mPerPx: 1 + } as ImgData; + + const controller = normalizeCompositeController(image, { + type: 'composite', + variables: { + alpha: { enabled: true, color: 'red', minmax: [0, 1] } + } + }); + + const screen = render(CompositeChannelTable, { + props: { + image, + controller, + onSelect: vi.fn(), + onRequestExpand: vi.fn(), + maxNameWidth: 80 + } + }); + + await flush(); + + const rows = screen.container.querySelectorAll('tr[aria-label$="controls"]'); + expect(rows).toHaveLength(2); + expect(controller.variables.beta).toBeDefined(); + screen.unmount(); +}); + test('localStorage restores composite control state on remount', async () => { const first = await setupCompositeControl(); diff --git a/src/lib/ui/background/imgControl.svelte b/src/lib/ui/background/imgControl.svelte index 1a4e6ad9..02e11f92 100644 --- a/src/lib/ui/background/imgControl.svelte +++ b/src/lib/ui/background/imgControl.svelte @@ -12,6 +12,7 @@ buildCompositeController, buildRgbController, cloneController, + normalizeCompositeController, selectChannelColor } from '$src/lib/ui/background/imgControlState'; import { Tooltip } from 'bits-ui'; @@ -55,7 +56,7 @@ if (nextImage.channels === 'rgb') { controller = buildRgbController(); } else if (Array.isArray(nextImage.channels)) { - controller = buildCompositeController(nextImage); + controller = normalizeCompositeController(nextImage, buildCompositeController(nextImage)); } else { throw new Error('Invalid channel configuration'); } diff --git a/src/lib/ui/background/imgControlState.ts b/src/lib/ui/background/imgControlState.ts index afeca688..1576cd0c 100644 --- a/src/lib/ui/background/imgControlState.ts +++ b/src/lib/ui/background/imgControlState.ts @@ -56,6 +56,42 @@ export function buildCompositeController(image: ImgData): CompCtrl { return { type: 'composite', variables }; } +export function normalizeCompositeController(image: ImgData, controller: CompCtrl): CompCtrl { + if (!Array.isArray(image.channels)) return controller; + const sqrtMax = Math.sqrt(image.maxVal ?? 0); + const keep = new Set(image.channels); + for (const key of Object.keys(controller.variables)) { + if (!keep.has(key)) { + delete controller.variables[key]; + } + } + + const palette = new Array(Math.ceil(image.channels.length / colors.length)) + .fill(colors) + .flat() as BandInfo['color'][]; + + image.channels.forEach((channel, idx) => { + const defaultColor = palette[idx] ?? colors[idx % colors.length]; + const existing = controller.variables[channel]; + if (existing) { + if (!Array.isArray(existing.minmax) || existing.minmax.length !== 2) { + existing.minmax = [0, sqrtMax]; + } + if (!existing.color) { + existing.color = defaultColor; + } + } else { + controller.variables[channel] = { + enabled: false, + color: defaultColor, + minmax: [0, sqrtMax] + }; + } + }); + + return controller; +} + export function restoreCompositeController(channels: CompositeChannels): CompCtrl | null { const snapshot = localStorage.getItem('imgCtrl'); if (!snapshot) return null; @@ -110,4 +146,3 @@ export function cloneController(ctrl: ImgCtrl | undefined): ImgCtrl | undefined if (!ctrl) return undefined; return JSON.parse(JSON.stringify(ctrl)) as ImgCtrl; } - diff --git a/src/lib/ui/mapp.ts b/src/lib/ui/mapp.ts index 273a306c..6443071a 100644 --- a/src/lib/ui/mapp.ts +++ b/src/lib/ui/mapp.ts @@ -148,15 +148,22 @@ export class Mapp extends Deferrable { ]); if (sample.featureParams) { - // Must have an active feature, otherwise renderComplete will not fire. - const selected = get(overlays)[get(sOverlay)]?.currFeature ?? - sample.overlayParams?.defaults?.[0] ?? { - group: sample.features[Object.keys(sample.features)[0]].name, - feature: sample.features[Object.keys(sample.features)[0]].featNames[0] - }; + const overlayInstance = get(overlays)[get(sOverlay)]; + let selected = overlayInstance?.currFeature; + if (!selected || !sample.hasFeature(selected)) { + const defaults = sample.overlayParams?.defaults ?? []; + selected = defaults.find((entry) => sample.hasFeature(entry)); + if (!selected) { + selected = await sample.firstFeature(); + } + } console.log('Selected', selected); - setHoverSelect({ selected }).catch(console.error); + try { + await setHoverSelect({ selected }); + } catch (err) { + console.error(err); + } } sEvent.set({ type: 'sampleUpdated' }); } diff --git a/src/lib/ui/overlays/points.ts b/src/lib/ui/overlays/points.ts index 80cb2a15..a31680c3 100644 --- a/src/lib/ui/overlays/points.ts +++ b/src/lib/ui/overlays/points.ts @@ -28,7 +28,7 @@ export class WebGLSpots extends MapComponent[]; - currSample?: string; + currSample?: Sample; currFeature?: FeatureAndGroup; currPx?: number; currLegend?: (string | number)[]; @@ -36,6 +36,7 @@ export class WebGLSpots extends MapComponent; z: number; // WebGLSpots only gets created after mount. @@ -53,6 +54,7 @@ export class WebGLSpots extends MapComponent { if (this.currStyleVariables.min === 0 && this.currStyleVariables.max === 0) { @@ -258,7 +270,7 @@ export class WebGLSpots extends MapComponent { + const feature = { group: 'group-1', feature: 'feat-1' } as const; + const coordsOne = new CoordsData({ + name: 'shared-coords', + shape: 'circle', + mPerPx: 1, + size: 5, + pos: [ + { x: 0, y: 0, id: 'first-0' }, + { x: 2, y: 3, id: 'first-1' } + ] + }); + + const { map, cleanup } = await renderMappHarness({ coords: coordsOne }); + const overlay = new WebGLSpots(map); + + const createSample = (coords: CoordsData): Sample => + ({ + name: 'duplicate-name', + async getFeature() { + const values = coords.pos!.map((_, idx) => idx); + return { + data: values, + dataType: 'quantitative' as const, + coords, + minmax: [0, values.length - 1] as [number, number], + unit: 'a.u.', + name: feature + }; + } + }) as unknown as Sample; + + const sampleOne = createSample(coordsOne); + await overlay.update(sampleOne, feature); + await wait(); + + const firstGeometry = overlay.source.getFeatures()[0]!.getGeometry()?.getCoordinates(); + + const coordsTwo = new CoordsData({ + name: 'shared-coords', + shape: 'circle', + mPerPx: 0.5, + size: 5, + pos: [ + { x: 20, y: 4, id: 'second-0' }, + { x: 25, y: -8, id: 'second-1' } + ] + }); + + const sampleTwo = createSample(coordsTwo); + await overlay.update(sampleTwo, feature); + await wait(); + + const expected = [ + coordsTwo.pos![0].x * coordsTwo.mPerPx, + -coordsTwo.pos![0].y * coordsTwo.mPerPx + ]; + const updatedGeometry = overlay.source.getFeatures()[0]!.getGeometry()?.getCoordinates(); + + expect(firstGeometry).not.toEqual(expected); + expect(updatedGeometry).toEqual(expected); + expect(overlay.coords).toBe(coordsTwo); + expect(overlay.currPx).toBeCloseTo(coordsTwo.sizePx); + + overlay.dispose(); + cleanup(); +}); diff --git a/src/lib/ui/overlays/spots.test.ts b/src/lib/ui/overlays/spots.test.ts index bb31bc0a..b6ee4dcc 100644 --- a/src/lib/ui/overlays/spots.test.ts +++ b/src/lib/ui/overlays/spots.test.ts @@ -1,3 +1,5 @@ +import { sFeatureData } from '$src/lib/store'; +import { get } from 'svelte/store'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const vectorSourceInstances: FakeVectorSource[] = []; @@ -189,6 +191,31 @@ describe('WebGLSpots', () => { expect(layer.options.variables?.opacity).toBeCloseTo(0.4); expect(layer.updateStyleVariables).toHaveBeenCalledWith(overlay.currStyleVariables); }); + + it('clears cached data when sample lacks the requested feature', async () => { + const { WebGLSpots } = await import('$src/lib/ui/overlays/points'); + const map = mapStub(); + const overlay = new WebGLSpots(map as any); + + // Seed layer with points so we can confirm they are cleared. + overlay.features = [{ set: vi.fn(), setId: vi.fn() }] as any; + overlay.coords = coords as any; + overlay.source.addFeatures([{ id: 1 } as any]); + + const missingSample = { + name: 'replacement', + getFeature: vi.fn().mockResolvedValue(undefined) + } as any; + + const result = await overlay.update(missingSample, { group: 'genes', feature: 'SNAP25' }); + + expect(result).toBe(false); + expect(overlay.source.clear).toHaveBeenCalled(); + expect(overlay.features).toBeUndefined(); + expect(overlay.coords).toBeUndefined(); + expect(overlay.currFeature).toBeUndefined(); + expect(get(sFeatureData)).toBeUndefined(); + }); }); describe('genSpotStyle', () => { diff --git a/src/routes/MainPage.svelte b/src/routes/MainPage.svelte index fecb0fc6..11963264 100644 --- a/src/routes/MainPage.svelte +++ b/src/routes/MainPage.svelte @@ -50,6 +50,13 @@ }); // Drops + function clearQueryString() { + if (window.history && window.location.search.length > 0) { + const nextUrl = window.location.pathname + window.location.hash; + window.history.replaceState({}, '', nextUrl); + } + } + function handleDrop(e: Event) { dragging = false; e.stopPropagation(); @@ -63,7 +70,13 @@ const handle = file.getAsFileSystemHandle() as Promise< FileSystemDirectoryHandle | FileSystemFileHandle >; - processHandle(handle, true).catch(console.error); + processHandle(handle, true) + .then((result) => { + if (result === 'folder') { + clearQueryString(); + } + }) + .catch(console.error); } let dragging = false; let dragTimeout: ReturnType;