Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f3c4030
refactor(control-bar): remove 3D plane selector and projectionPlane p…
peymanvahidi May 13, 2026
1ed4afc
refactor(scatter-plot): drop projectionPlane property and dead z assi…
peymanvahidi May 13, 2026
7ae6f74
refactor(data-processor): drop projectionPlane param + xz/yz remap
peymanvahidi May 13, 2026
2dbcffc
refactor(control-bar): drop dead isProjection3D and getProjectionPlan…
peymanvahidi May 13, 2026
7e37db7
chore(scatter-plot): drop stale 'z' from x/y/z fast-path comment
peymanvahidi May 13, 2026
55bdc49
feat(control-bar): add numeric filter types and matching helpers
peymanvahidi May 14, 2026
91a0262
test(control-bar): cover null-min case for between readiness
peymanvahidi May 14, 2026
d24f276
feat(control-bar): evaluate numeric filter conditions via discriminat…
peymanvahidi May 14, 2026
1dcd75b
refactor(control-bar): drop unused isNumericCondition type guard
peymanvahidi May 14, 2026
d8a6e7d
feat(control-bar): add query-numeric-input component
peymanvahidi May 14, 2026
b7f7e3b
style(control-bar): match operator select chevron to logical-op select
peymanvahidi May 14, 2026
9fcc152
feat(control-bar): render numeric input for numeric filter annotations
peymanvahidi May 14, 2026
c897628
refactor(control-bar): narrow numeric-changed handler event type
peymanvahidi May 14, 2026
068d1df
fix(control-bar): clear value picker on annotation switch + cover num…
peymanvahidi May 14, 2026
dda6b25
refactor(control-bar): drop min/max hints from numeric input placehol…
peymanvahidi May 18, 2026
7727af2
fix(control-bar): apply filter query via filteredProteinIds channel (…
peymanvahidi May 29, 2026
98effa7
fix(control-bar): make numeric filtering reachable and consistent und…
peymanvahidi May 29, 2026
174ba3a
chore(control-bar): polish — docs, dead code, and intent-pinning tests
peymanvahidi May 29, 2026
3d55218
fix(scatter-plot): keep global originalIndex under a query filter (#2…
peymanvahidi May 30, 2026
318148f
Merge origin/main into refactor/control-bar-and-filtering
peymanvahidi Jun 1, 2026
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
4 changes: 4 additions & 0 deletions app/src/explore/data-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@
plotElement.selectedProteinIds = [];
plotElement.selectionMode = false;
plotElement.hiddenAnnotationValues = [];
// A query filter is scoped to the previous dataset — clear it so it can't carry
// stale protein ids onto the new dataset.
plotElement.filteredProteinIds = [];
plotElement.filtersActive = false;
plotElement.requestUpdate('data', previousData);
}

Expand Down Expand Up @@ -135,20 +139,20 @@
return null;
}

console.log('Loading new data:', newData);

Check warning on line 142 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error

Check warning on line 142 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
const startTime = performance.now();
const dataSize = newData.protein_ids.length;
const isLargeDataset = dataSize > 1000;
const initialView = resolveInitialView(newData);
const resolvedInitialView = resolveRenderableView(newData, initialView);

console.log('Dataset analysis:', {

Check warning on line 149 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error

Check warning on line 149 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
size: dataSize.toLocaleString(),
willUseProgressiveLoading: isLargeDataset,
});

if (isLargeDataset) {
console.log(

Check warning on line 155 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error

Check warning on line 155 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
`Large dataset detected (${dataSize.toLocaleString()} proteins) - using optimized loading pipeline`,
);
updateOverlayForStep(
Expand All @@ -173,7 +177,7 @@

await yieldToBrowser();

console.log('Updating scatterplot with new data...');

Check warning on line 180 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error

Check warning on line 180 in app/src/explore/data-renderer.ts

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Unexpected console statement. Only these console methods are allowed: warn, error
controlBar.autoSync = false;
legendElement.autoSync = false;

Expand Down
2 changes: 1 addition & 1 deletion docs/explore/control-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Your current projection is reflected in the page URL, so refresh, browser back/f
:::

::: tip 3D Projections
When a 3D projection is available, a **plane selector** (XY / XZ / YZ) appears, letting you view different 2D slices of the 3D space.
3D projections load normally and are shown as their X/Y view; the third (Z) dimension is not rendered in the web viewer.
:::

## 2. Annotation Selector
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
EXPORT_DEFAULTS,
isProjection3D,
getProjectionPlane,
shouldDisableSelection,
getSelectionDisabledMessage,
toggleProteinSelection,
Expand Down Expand Up @@ -92,48 +90,6 @@ describe('control-bar-helpers', () => {
});
});

describe('isProjection3D', () => {
const projectionsMeta = [
{ name: 'projection2D', metadata: { dimension: 2 as const } },
{ name: 'projection3D', metadata: { dimension: 3 as const } },
{ name: 'projectionNoMeta' },
];

it('returns true for 3D projection', () => {
expect(isProjection3D('projection3D', projectionsMeta)).toBe(true);
});

it('returns false for 2D projection', () => {
expect(isProjection3D('projection2D', projectionsMeta)).toBe(false);
});

it('returns false for projection without metadata', () => {
expect(isProjection3D('projectionNoMeta', projectionsMeta)).toBe(false);
});

it('returns false for non-existent projection', () => {
expect(isProjection3D('nonExistent', projectionsMeta)).toBe(false);
});

it('returns false for empty projections array', () => {
expect(isProjection3D('projection3D', [])).toBe(false);
});
});

describe('getProjectionPlane', () => {
it('returns current plane for 3D projection', () => {
expect(getProjectionPlane(true, 'xz')).toBe('xz');
expect(getProjectionPlane(true, 'yz')).toBe('yz');
expect(getProjectionPlane(true, 'xy')).toBe('xy');
});

it('returns xy for 2D projection regardless of current plane', () => {
expect(getProjectionPlane(false, 'xz')).toBe('xy');
expect(getProjectionPlane(false, 'yz')).toBe('xy');
expect(getProjectionPlane(false, 'xy')).toBe('xy');
});
});

describe('shouldDisableSelection', () => {
it('returns true when data size is 0', () => {
expect(shouldDisableSelection(0)).toBe(true);
Expand Down
21 changes: 0 additions & 21 deletions packages/core/src/components/control-bar/control-bar-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,6 @@ export const EXPORT_DEFAULTS = {
INCLUDE_LEGEND: true,
};

/**
* Check if projection is 3D based on metadata
*/
export function isProjection3D(
projectionName: string,
projectionsMeta: Array<{ name: string; metadata?: { dimension?: 2 | 3 } }>,
): boolean {
const meta = projectionsMeta.find((p) => p.name === projectionName);
return meta?.metadata?.dimension === 3;
}

/**
* Get appropriate plane for projection
*/
export function getProjectionPlane(
is3D: boolean,
currentPlane: 'xy' | 'xz' | 'yz',
): 'xy' | 'xz' | 'yz' {
return is3D ? currentPlane : 'xy';
}

/**
* Validate selection mode based on data size
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @vitest-environment jsdom
*
* Behavioural contract for applying a filter query from the control bar.
*
* Regression coverage for the "re-apply shrinks the result" bug (issue #257 and
* the PR #259 report): applying `protein_family = phospholipase A2` matched 546
* proteins, re-applying the unchanged query matched 19, and a third apply only
* faded points. Root cause: the query was evaluated against the full materialized
* dataset but the matched indices were translated back through the *isolated*
* subset returned by `getCurrentData()`, and every apply stacked another
* isolation layer.
*
* The fix routes a filter query through the dedicated, idempotent
* `filteredProteinIds` / `filtersActive` channel on the scatter plot — a filter
* is not a selection and is not an isolation. These tests pin that contract.
*
* The control bar is created via document.createElement (no WebGL scatter plot
* is mounted); a lightweight stub stands in for the scatter plot so we can
* assert exactly what the apply/reset handlers write.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import './control-bar';
import type { FilterQuery } from './query-types';
import type { ProtspaceData } from './types';

interface StubScatterplot {
filteredProteinIds?: string[];
filtersActive?: boolean;
selectedProteinIds?: string[];
isolateSelection: ReturnType<typeof vi.fn>;
resetIsolation: ReturnType<typeof vi.fn>;
getCurrentData: ReturnType<typeof vi.fn>;
getMaterializedData: ReturnType<typeof vi.fn>;
// The control bar treats the scatter plot as an Element (it (de)registers DOM
// listeners on it), so the stub must answer these even though we don't use them.
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
}

interface ControlBarInternals extends HTMLElement {
_scatterplotElement: StubScatterplot | null;
_currentData: ProtspaceData | undefined;
filterActive: boolean;
filterQuery: FilterQuery;
_handleQueryApply(event: CustomEvent<{ matchedIndices: Set<number> }>): void;
_handleQueryReset(): void;
updateComplete: Promise<unknown>;
}

/** Build a full dataset of `count` proteins: p0, p1, … p{count-1}. */
function makeFullData(count: number): ProtspaceData {
return {
protein_ids: Array.from({ length: count }, (_, i) => `p${i}`),
};
}

function applyEvent(matchedIndices: Set<number>): CustomEvent<{ matchedIndices: Set<number> }> {
return new CustomEvent('query-apply', { detail: { matchedIndices } });
}

describe('control-bar filter query apply', () => {
let controlBar: ControlBarInternals;
let scatter: StubScatterplot;

beforeEach(async () => {
document.body.innerHTML = '';
controlBar = document.createElement('protspace-control-bar') as ControlBarInternals;
controlBar.autoSync = false;
document.body.appendChild(controlBar);
await controlBar.updateComplete;

scatter = {
// sentinel selection — must survive a filter apply untouched
selectedProteinIds: ['sentinel'],
isolateSelection: vi.fn(),
resetIsolation: vi.fn(),
// getCurrentData returns the *isolated subset*. The old buggy code used this
// to translate matched indices; the fix must never read it for translation.
getCurrentData: vi.fn(() => ({ protein_ids: ['p0', 'p1'] })),
getMaterializedData: vi.fn(() => makeFullData(100)),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};

controlBar._scatterplotElement = scatter;
// The query builder evaluates against the full materialized data, exposed as
// _currentData. Matched indices are positions in THIS array.
controlBar._currentData = makeFullData(100);
});

it('applies a query via the filter channel without selecting or isolating', () => {
// family "A" = first 30 proteins
const matched = new Set(Array.from({ length: 30 }, (_, i) => i));

controlBar._handleQueryApply(applyEvent(matched));

const expectedIds = Array.from({ length: 30 }, (_, i) => `p${i}`);
expect(scatter.filteredProteinIds).toEqual(expectedIds);
expect(scatter.filtersActive).toBe(true);
expect(controlBar.filterActive).toBe(true);

// A filter is not an isolation and not a selection.
expect(scatter.isolateSelection).not.toHaveBeenCalled();
expect(scatter.selectedProteinIds).toEqual(['sentinel']);
});

it('is idempotent: re-applying the same query yields the same matches', () => {
const matched = new Set(Array.from({ length: 30 }, (_, i) => i));
const expectedIds = Array.from({ length: 30 }, (_, i) => `p${i}`);

controlBar._handleQueryApply(applyEvent(matched));
expect(scatter.filteredProteinIds).toEqual(expectedIds);

// Second apply with the unchanged query — must NOT shrink (was 30 → 19 → fade).
controlBar._handleQueryApply(applyEvent(new Set(matched)));
expect(scatter.filteredProteinIds).toEqual(expectedIds);
expect(scatter.filtersActive).toBe(true);

// Third apply — still stable, still no isolation stacking.
controlBar._handleQueryApply(applyEvent(new Set(matched)));
expect(scatter.filteredProteinIds).toEqual(expectedIds);
expect(scatter.isolateSelection).not.toHaveBeenCalled();
});

it('replaces (does not stack) when a narrower query is applied next', () => {
controlBar._handleQueryApply(applyEvent(new Set(Array.from({ length: 30 }, (_, i) => i))));
expect(scatter.filteredProteinIds).toHaveLength(30);

controlBar._handleQueryApply(applyEvent(new Set(Array.from({ length: 12 }, (_, i) => i))));
expect(scatter.filteredProteinIds).toEqual(Array.from({ length: 12 }, (_, i) => `p${i}`));
});

it('clears the filter channel on reset, leaving manual isolation alone', () => {
controlBar._handleQueryApply(applyEvent(new Set([0, 1, 2])));
expect(scatter.filtersActive).toBe(true);

controlBar._handleQueryReset();

expect(scatter.filteredProteinIds).toEqual([]);
expect(scatter.filtersActive).toBe(false);
expect(controlBar.filterActive).toBe(false);
// Reset re-seeds an empty condition row so the builder shows a fresh query.
expect(controlBar.filterQuery).toHaveLength(1);
});
});
Loading
Loading