diff --git a/e2e-tests/elementView.spec.ts b/e2e-tests/elementView.spec.ts index c5e850e5..3636d2cc 100644 --- a/e2e-tests/elementView.spec.ts +++ b/e2e-tests/elementView.spec.ts @@ -6,7 +6,8 @@ import { beforeTest } from './common'; test.beforeEach(beforeTest); /** - * Drags the mouse from the center of the element to the specified offset + * Drags the mouse from the center of the element to the specified offset. + * Doesn't quite work in firefox; use caution * @see https://stackoverflow.com/a/71147367 * @param element The element to drag over * @param xOffset The x offset to drag to @@ -28,7 +29,7 @@ async function dragElement(element: Locator, xOffset: number, yOffset: number, p await page.mouse.up(); } -test('Element View', async ({ page }) => { +test('Element View', async ({ page, browserName }) => { await page.goto('http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193'); // Make selection @@ -112,6 +113,10 @@ test('Element View', async ({ page }) => { // Check that the selection chip is visible after selecting await elementViewToggle.click(); await dragElement(page.locator('canvas'), 150, 0, page); + // For some reason, in firefox, the dragElement() method doesn't quite work right and the end of the drag + // is off the element, which doesn't fire the select handler. Clicking the canvas in firefox fires the event, + // but clicking in chromium/webkit resets the selection & breaks the test + if (browserName === 'firefox') await page.locator('canvas').first().click(); const elementSelectionChip = await page.getByLabel('Selected elements Atts: Age'); await expect(elementSelectionChip).toBeVisible(); @@ -155,3 +160,143 @@ test('Element View', async ({ page }) => { await expect(schoolBlueHairMaleSelectionRect).toBeVisible(); await expect(schoolMale3rdPoly).toBeVisible(); }); + +/** + * Clears the selection in the element view; element view must be open + * @param page The page to perform the clear selection on + */ +async function clearSelection(page: Page): Promise { + await page.getByRole('button', { name: 'Clear' }).click(); +} + +/** + * Sets a query in the element view; element view must be open + * @param page The current testing page + * @param att The attribute to query + * @param type The type of query + * @param query The query string + */ +async function setQuery(page: Page, att: string, type: string, query: string): Promise { + await page.getByLabel('Attribute Name').click(); + await page.getByRole('option', { name: att }).click(); + await page.getByLabel('Query Type').click(); + await page.getByRole('option', { name: type, exact: true }).click(); + await page.getByPlaceholder('Query').click(); + await page.getByPlaceholder('Query').fill(query); + await page.getByRole('button', { name: 'Apply' }).click(); +} + +test('Query Selection', async ({ page }) => { + await page.goto('http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193'); + await page.getByLabel('Element View Sidebar Toggle').click(); + await page.locator('[id="Subset_School\\~\\&\\~Male"] g').filter({ hasText: /^Blue Hair$/ }).locator('circle').click(); + await page.getByLabel('Selected intersection School').click(); + + // Selected elements for testing + const ralphCell = page.getByRole('cell', { name: 'Ralph' }); + const age8Cell = page.getByRole('cell', { name: '8' }); + const bartCell = page.getByRole('cell', { name: 'Bart' }); + const age10Cell1 = page.getByRole('cell', { name: '10' }).first(); + const age10Cell2 = page.getByRole('cell', { name: '10' }).nth(1); + const martinCell = page.getByRole('cell', { name: 'Martin Prince' }); + const schoolSelectPoly = page.locator('#Subset_School polygon').nth(1); + const maleSelectPoly = page.locator('#Subset_Male polygon').nth(1); + const schoolMaleSelectPoly = page.locator('[id="Subset_School\\~\\&\\~Male"] polygon').nth(1); + + // Test less than query + await setQuery(page, 'Age', 'less than', '10'); + + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(ralphCell).toBeVisible(); + await expect(age8Cell).toBeVisible(); + await expect(bartCell).not.toBeVisible(); + await expect(age10Cell1).not.toBeVisible(); + await expect(age10Cell2).not.toBeVisible(); + await expect(martinCell).not.toBeVisible(); + await expect(schoolSelectPoly).toBeVisible(); + await expect(maleSelectPoly).not.toBeVisible(); + + // Test greater than query + await clearSelection(page); + await setQuery(page, 'Age', 'greater than', '8'); + + await expect(bartCell).toBeVisible(); + await expect(age10Cell1).toBeVisible(); + await expect(age10Cell2).toBeVisible(); + await expect(martinCell).toBeVisible(); + await expect(maleSelectPoly).toBeVisible(); + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(ralphCell).not.toBeVisible(); + await expect(age8Cell).not.toBeVisible(); + await expect(schoolSelectPoly).not.toBeVisible(); + + // Test contains query + await clearSelection(page); + await setQuery(page, 'Name', 'contains', 't'); + + await expect(bartCell).toBeVisible(); + await expect(age10Cell1).toBeVisible(); + await expect(age10Cell2).toBeVisible(); + await expect(martinCell).toBeVisible(); + await expect(maleSelectPoly).toBeVisible(); + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(ralphCell).not.toBeVisible(); + await expect(age8Cell).not.toBeVisible(); + await expect(schoolSelectPoly).not.toBeVisible(); + + // Test equals query + await clearSelection(page); + await setQuery(page, 'Name', 'equals', 'Bart'); + + await expect(bartCell).toBeVisible(); + await expect(age10Cell1).toBeVisible(); + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(age10Cell2).not.toBeVisible(); + await expect(martinCell).not.toBeVisible(); + await expect(maleSelectPoly).not.toBeVisible(); + await expect(ralphCell).not.toBeVisible(); + await expect(age8Cell).not.toBeVisible(); + await expect(schoolSelectPoly).not.toBeVisible(); + + // Test length equals query + await clearSelection(page); + await setQuery(page, 'Name', 'length equals', '5'); + + await expect(ralphCell).toBeVisible(); + await expect(age8Cell).toBeVisible(); + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(bartCell).not.toBeVisible(); + await expect(age10Cell1).not.toBeVisible(); + await expect(age10Cell2).not.toBeVisible(); + await expect(martinCell).not.toBeVisible(); + await expect(schoolSelectPoly).not.toBeVisible(); + await expect(maleSelectPoly).not.toBeVisible(); + + // Test regex query + await clearSelection(page); + await setQuery(page, 'Name', 'regex', '^([A-z]+)$'); + + await expect(page.locator('[id="Subset_Evil\\~\\&\\~Male"] polygon').nth(1)).not.toBeVisible(); + await expect(bartCell).toBeVisible(); + await expect(age10Cell1).toBeVisible(); + await expect(ralphCell).toBeVisible(); + await expect(age8Cell).toBeVisible(); + await expect(martinCell).not.toBeVisible(); + await expect(age10Cell2).not.toBeVisible(); + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(schoolSelectPoly).toBeVisible(); + await expect(maleSelectPoly).toBeVisible(); + + // Test clear selection + await clearSelection(page); + await expect(bartCell).toBeVisible(); + await expect(age10Cell1).toBeVisible(); + await expect(ralphCell).toBeVisible(); + await expect(age8Cell).toBeVisible(); + await expect(martinCell).toBeVisible(); + await expect(age10Cell2).toBeVisible(); + // Only visible because the intersection is selected + await expect(schoolMaleSelectPoly).toBeVisible(); + await expect(schoolSelectPoly).not.toBeVisible(); + await expect(maleSelectPoly).not.toBeVisible(); +}); diff --git a/packages/core/src/convertConfig.ts b/packages/core/src/convertConfig.ts index 6d09fa58..9098aec7 100644 --- a/packages/core/src/convertConfig.ts +++ b/packages/core/src/convertConfig.ts @@ -1,5 +1,5 @@ import { - AggregateBy, Bookmark, BookmarkedSelection, Column, ColumnName, Histogram, PlotInformation, Row, Scatterplot, + AggregateBy, Bookmark, NumericalBookmark, Column, ColumnName, Histogram, PlotInformation, Row, Scatterplot, SortByOrder, SortVisibleBy, UpsetConfig, } from './types'; import { isUpsetConfig } from './typecheck'; @@ -52,7 +52,7 @@ type PreVersionConfig = { }; allSets: Column[]; selected: Row | null; - elementSelection: BookmarkedSelection | null; + elementSelection: NumericalBookmark | null; }; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e044add5..785207ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,3 +8,4 @@ export * from './defaultConfig'; export * from './convertConfig'; export * from './typecheck'; export * from './typeutils'; +export * from './utils'; diff --git a/packages/core/src/typecheck.ts b/packages/core/src/typecheck.ts index 2ae43b5e..e2f41169 100644 --- a/packages/core/src/typecheck.ts +++ b/packages/core/src/typecheck.ts @@ -1,5 +1,9 @@ import { - Aggregate, AggregateBy, aggregateByList, AltText, AttributePlots, AttributePlotType, BaseElement, BaseIntersection, Bookmark, BookmarkedSelection, Column, ElementSelection, Histogram, PlotInformation, Row, RowType, Scatterplot, Subset, Subsets, UpsetConfig, + Aggregate, AggregateBy, aggregateByList, AltText, AttributePlots, AttributePlotType, BaseElement, BaseIntersection, Bookmark, NumericalBookmark, Column, NumericalQuery, Histogram, PlotInformation, Row, RowType, Scatterplot, Subset, Subsets, UpsetConfig, + ElementQuery, + ElementQueryType, + ElementBookmark, + ElementSelection, } from './types'; import { deepCopy } from './utils'; @@ -103,11 +107,11 @@ export function isAltText(val: unknown): val is AltText { } /** - * Validates that the given value is an ElementSelection. + * Validates that the given value is a NumericalQuery. * @param value The value to check. - * @returns whether the value is an ElementSelection. + * @returns whether the value is a NumericalQuery. */ -export function isElementSelection(value: unknown): value is ElementSelection { +export function isNumericalQuery(value: unknown): value is NumericalQuery { return ( isObject(value) && Object.values(value).every((v) => Array.isArray(v) @@ -117,6 +121,23 @@ export function isElementSelection(value: unknown): value is ElementSelection { ); } +/** + * Type guard for ElementQuery + * @param val The value to check. + * @returns whether the value is a ElementQuery + */ +export function isElementQuery(val: unknown): val is ElementQuery { + return ( + isObject(val) + && Object.hasOwn(val, 'att') + && Object.hasOwn(val, 'type') + && Object.hasOwn(val, 'query') + && typeof (val as ElementQuery).att === 'string' + && Object.values(ElementQueryType).includes((val as ElementQuery).type) + && typeof (val as ElementQuery).query === 'string' + ); +} + /** * Type guard for Scatterplot * @param s variable to check @@ -163,7 +184,11 @@ export function isBookmark(b: unknown): b is Bookmark { && Object.hasOwn(b, 'type') && typeof (b as Bookmark).id === 'string' && typeof (b as Bookmark).label === 'string' - && ((b as Bookmark).type === 'intersection' || (b as Bookmark).type === 'elements'); + && ( + (b as Bookmark).type === 'intersection' + || (b as Bookmark).type === 'numerical' + || (b as Bookmark).type === 'element' + ); } /** @@ -246,15 +271,36 @@ export function isRow(r: unknown): r is Row { } /** - * Type guard for BookmarkedSelection + * Type guard for NumericalBookmark + * @param b variable to check + * @returns {boolean} + */ +export function isNumericalBookmark(b: unknown): b is NumericalBookmark { + return isBookmark(b) + && b.type === 'numerical' + && Object.hasOwn(b, 'selection') + && isNumericalQuery((b as NumericalBookmark).selection); +} + +/** + * Type guard for ElementBookmark * @param b variable to check * @returns {boolean} */ -export function isBookmarkedSelection(b: unknown): b is BookmarkedSelection { +export function isElementBookmark(b: unknown): b is ElementBookmark { return isBookmark(b) - && b.type === 'elements' + && b.type === 'element' && Object.hasOwn(b, 'selection') - && isElementSelection((b as BookmarkedSelection).selection); + && isElementQuery((b as ElementBookmark).selection); +} + +/** + * Determines if the given object is an ElementSelection. + * @param e The object to check. + * @returns {boolean} Whether the object is an ElementSelection. + */ +export function isElementSelection(e: unknown): e is ElementSelection { + return isNumericalBookmark(e) || isElementBookmark(e); } /** @@ -494,7 +540,7 @@ export function isUpsetConfig(config: unknown): config is UpsetConfig { } // elementSelection - if (!(elementSelection === null || isBookmarkedSelection(elementSelection))) { + if (!(elementSelection === null || isElementSelection(elementSelection))) { console.warn('Upset config error: Element selection is not a bookmarked selection'); return false; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b5998247..c1be753b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -287,6 +287,53 @@ export enum AttributePlotType { */ export type AttributePlots = Record; +/** + * Possible string types for an element query + */ +// linter is saying this is already declared... on this line +// eslint-disable-next-line no-shadow +export enum ElementQueryType { + EQUALS = 'equals', + CONTAINS = 'contains', + LENGTH = 'length equals', + REGEX = 'regex', + LESS_THAN = 'less than', + GREATER_THAN = 'greater than', +} + +/** + * Represents a selection of elements based on a comparison between an attribute and a query string. + */ +export type ElementQuery = { + /** + * Name of the attribute being queried upon + */ + att: string, + /** + * Type of the query; determines the mechanism used to evaluate whether the value of att + * on a given element matches this query + */ + type: ElementQueryType, + /** + * The query string. To be included in this query, the value of att on a given + * element must match this query string according to the rules set by the type. + */ + query: string, +} + +/** + * Represents a selection of elements based on their numerical attributes, + * currently only from brushes in the element view. + * Maps attribute names to an array with the minimum and maximum + * values of the selection over each attribute. + * + * @privateRemarks + * This *needs* to match the data format outputted by Vega-Lite to the 'brush' signal in + * upset/src/components/ElementView/ElementVisualization.tsx. + * This is typechecked by isNumericalQuery in typecheck.ts; changes here must be reflected there. + */ +export type NumericalQuery = {[attName: string] : [number, number]}; + /** * Base representation of a bookmarkable type * @privateRemarks typechecked by isBookmark in typecheck.ts; changes here must be reflected there @@ -303,7 +350,7 @@ export type Bookmark = { /** * Subtype of the bookmark; used to determine what fields are available at runtime */ - type: 'intersection' | 'elements'; + type: 'intersection' | 'numerical' | 'element'; }; /** @@ -321,32 +368,41 @@ export type BookmarkedIntersection = Bookmark & { } /** - * Represents a selection of elements in the Element View. - * Maps attribute names to an array with the minimum and maximum - * values of the selection over each attribute. - * - * @privateRemarks - * This *needs* to match the data format outputted by Vega-Lite to the 'brush' signal in - * upset/src/components/ElementView/ElementVisualization.tsx. - * This is typechecked by isElementSelection in typecheck.ts; changes here must be reflected there. + * Represents a bookmarked element selection based on numerical attributes, created in the Element View. + * @privateRemarks typechecked by isNumericalBookmark in typecheck.ts; changes here must be reflected there */ -export type ElementSelection = {[attName: string] : [number, number]}; +export type NumericalBookmark = Bookmark & { + /** + * The selection parameters + */ + selection: NumericalQuery; + /** + * Indicates type at runtime + */ + type: 'numerical'; +} /** - * Represents a bookmarked element selection, created in the Element View. - * @privateRemarks typechecked by isBookmarkedSelection in typecheck.ts; changes here must be reflected there + * Represents a bookmarked element selection based on attribute comparisons, created in the element view + * @privateRemarks typechecked by isElementBookmark in typecheck.ts; changes here must be reflected there */ -export type BookmarkedSelection = Bookmark & { +export type ElementBookmark = Bookmark & { /** - * The selection parameters + * Selection parameters */ - selection: ElementSelection; + selection: ElementQuery; /** - * Indicates type at runtime + * Indicates type at runtim */ - type: 'elements'; + type: 'element'; } +/** + * A bookmark which represents a selection of elements + * @privateRemarks typechecked by isElementSelection in typecheck.ts; changes here must be reflected there + */ +export type ElementSelection = NumericalBookmark | ElementBookmark; + /** * Represents the alternative text for an Upset plot. * @privateRemarks typechecked by isAltText in typecheck.ts; changes here must be reflected there @@ -415,7 +471,7 @@ export type UpsetConfig = { /** * Selected elements (data points) in the Element View. */ - elementSelection: BookmarkedSelection | null; + elementSelection: ElementSelection | null; version: '0.1.0'; useUserAlt: boolean; userAltText: AltText | null; diff --git a/packages/core/src/typeutils.ts b/packages/core/src/typeutils.ts index 7eb5b750..9449a3e6 100644 --- a/packages/core/src/typeutils.ts +++ b/packages/core/src/typeutils.ts @@ -1,6 +1,6 @@ import { Aggregate, - Aggregates, Bookmark, BookmarkedIntersection, BookmarkedSelection, ElementSelection, Row, Rows, SetMembershipStatus, Subset, Subsets, + Aggregates, Bookmark, BookmarkedIntersection, ElementBookmark, ElementQuery, NumericalBookmark, NumericalQuery, Row, Rows, SetMembershipStatus, Subset, Subsets, } from './types'; import { hashString } from './utils'; @@ -69,7 +69,7 @@ export function isBookmarkedIntersection(b: Bookmark): b is BookmarkedIntersecti * @param {number} decimalPlaces The number of decimal places to use when comparing equality of numbers, default 4 * @returns Whether a and b are equal */ -export function elementSelectionsEqual(a: ElementSelection | undefined, b: ElementSelection | undefined, decimalPlaces = 4): boolean { +export function numericalQueriesEqual(a: NumericalQuery | undefined, b: NumericalQuery | undefined, decimalPlaces = 4): boolean { // We want undefined == {} if (!a || Object.keys(a).length === 0) { return (!b || Object.keys(b).length === 0); @@ -98,7 +98,7 @@ export function elementSelectionsEqual(a: ElementSelection | undefined, b: Eleme * @param selection The numerical attribute query. * @returns The element selection. */ -export function elementSelectionToBookmark(selection: ElementSelection): BookmarkedSelection { +export function numericalQueryToBookmark(selection: NumericalQuery): NumericalBookmark { // Normalizing prevents floating point error from causing different hashes const norm = (i : number) => Math.abs(Math.round(i * 10000)); @@ -125,7 +125,24 @@ export function elementSelectionToBookmark(selection: ElementSelection): Bookmar return { id: i.toString(), label, - type: 'elements', + type: 'numerical', + selection, + }; +} + +/** + * Converts an element selection to a bookmark. + * Generates the ID by hashing the selection and labels the bookmark with the selection parameters. + * @param selection The element query. + * @returns The element selection. + */ +export function ElementQueryToBookmark(selection: ElementQuery): ElementBookmark { + const i = hashString(JSON.stringify(selection)); + const label = `${selection.att} ${selection.type} ${selection.query}`; + return { + id: i.toString(), + label, + type: 'element', selection, }; } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index d69cd7f7..bb30fbe8 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,3 +1,6 @@ +import { isNumericalBookmark, isElementBookmark } from './typecheck'; +import { ElementSelection, Item, ElementQueryType } from './types'; + /** * DEPRECATED: Currently serves only as an alias for structuredClone; use that instead. * Create a deep copy (with all fields recursively copied) of an object using structured cloning; @@ -27,3 +30,47 @@ export function hashString(str: string): number { } return hash; } + +/** + * Filter items based on a selection + * @param items Items to filter + * @param filter Selection to filter by + * @returns Filtered items + */ +export function filterItems(items: Item[], filter: ElementSelection): Item[] { + const result: Item[] = []; + if (isNumericalBookmark(filter)) { + return items.filter( + (item) => Object.entries(filter.selection).every( + ([key, value]) => typeof item[key] === 'number' + && item[key] as number >= value[0] + && item[key] as number <= value[1], + ), + ); + } if (isElementBookmark(filter)) { + const { att } = filter.selection; + const { query } = filter.selection; + + return items.filter((item) => { + if (!Object.hasOwn(item, att)) return false; + + switch (filter.selection.type) { + case ElementQueryType.CONTAINS: + return (`${item[att]}`).includes(query); + case ElementQueryType.EQUALS: + return (`${item[att]}` === query); + case ElementQueryType.LENGTH: + return ((`${item[att]}`).length === Number(query)); + case ElementQueryType.REGEX: + return (new RegExp(query).test(`${item[att]}`)); + case ElementQueryType.GREATER_THAN: + return `${item[att]}`.localeCompare(query, undefined, { numeric: typeof item[att] === 'number' }) > 0; + case ElementQueryType.LESS_THAN: + return `${item[att]}`.localeCompare(query, undefined, { numeric: typeof item[att] === 'number' }) < 0; + default: + } + return false; + }); + } + return result; +} diff --git a/packages/upset/src/atoms/dataAtom.ts b/packages/upset/src/atoms/dataAtom.ts index 4006b552..22b6ca92 100644 --- a/packages/upset/src/atoms/dataAtom.ts +++ b/packages/upset/src/atoms/dataAtom.ts @@ -1,5 +1,5 @@ -import { CoreUpsetData } from '@visdesignlab/upset2-core'; -import { atom } from 'recoil'; +import { ColumnName, CoreUpsetData } from '@visdesignlab/upset2-core'; +import { atom, selector } from 'recoil'; /** * Atom to store the data for the Upset plot @@ -8,3 +8,17 @@ export const dataAtom = atom>({ key: 'data', default: {}, }); + +/** + * Returns all columns that can be used in a string query, ie are not numeric, not set columns, + * and not private columns that start with _ + */ +export const queryColumnsSelector = selector({ + key: 'data-columns', + get: ({ get }) => { + const BUILTIN_COLS = ['_id', '_from', '_to', '_key', '_rev']; + const data = get(dataAtom); + return data.columns.filter((col) => !data.setColumns.includes(col) + && !BUILTIN_COLS.includes(col)); + }, +}); diff --git a/packages/upset/src/atoms/elementsSelectors.ts b/packages/upset/src/atoms/elementsSelectors.ts index 70586c94..374c273c 100644 --- a/packages/upset/src/atoms/elementsSelectors.ts +++ b/packages/upset/src/atoms/elementsSelectors.ts @@ -1,7 +1,10 @@ import { Aggregate, BaseIntersection, - BookmarkedSelection, ElementSelection, Item, Row, flattenedOnlyRows, getItems, + NumericalQuery, Item, Row, flattenedOnlyRows, getItems, + ElementSelection, + filterItems, + ElementQuery, } from '@visdesignlab/upset2-core'; import { selector, selectorFamily } from 'recoil'; import { @@ -118,18 +121,35 @@ export const elementItemMapSelector = selectorFamily({ * Gets the current selection of elements * @returns The current selection of elements */ -export const selectedElementSelector = selector({ +export const selectedElementSelector = selector({ key: 'config-element-selection', get: ({ get }) => get(upsetConfigAtom).elementSelection, }); /** - * Gets the parameters for the current selection of elements, - * ie the 'selected' property of the selectedElementsSelector + * Gets the parameters for the current numerical selection of elements, + * ie the 'selected' property of the selectedElementsSelector. + * If the current selection is not numerical, returns undefined. */ -export const elementSelectionParameters = selector({ +export const currentNumericalQuery = selector({ key: 'config-current-element-selection', - get: ({ get }) => get(selectedElementSelector)?.selection, + get: ({ get }) => { + const elementSelection = get(selectedElementSelector); + return elementSelection?.type === 'numerical' ? elementSelection.selection : undefined; + }, +}); + +/** + * Gets the parameters for the current selection of elements, + * ie the 'selected' property of the selectedElementsSelector. + * If the current selection is not an element query, returns undefined. + */ +export const currentElementQuery = selector({ + key: 'config-current-element-query', + get: ({ get }) => { + const elementSelection = get(selectedElementSelector); + return elementSelection?.type === 'element' ? elementSelection.selection : undefined; + }, }); /** @@ -141,15 +161,10 @@ export const selectedItemsSelector = selector({ get: ({ get }) => { const bookmarks = get(bookmarkSelector); const items: Item[] = get(elementItemMapSelector(bookmarks.map((b) => b.id))); - const selection = get(elementSelectionParameters); + const selection = get(selectedElementSelector); if (!selection) return []; - const result: Item[] = []; - items.forEach((item) => { - if (Object.entries(selection).every(([key, value]) => typeof item[key] === 'number' && - item[key] as number >= value[0] && item[key] as number <= value[1])) { result.push(item); } - }); - return result; + return filterItems(items, selection); }, }); @@ -168,21 +183,11 @@ export const subsetSelectedCount = selectorFamily({ key: 'subset-selected', get: (id: string) => ({ get }) => { const items = get(elementSelector(id)); - const selection = get(elementSelectionParameters); - - if (!selection || Object.keys(selection).length === 0) return 0; - - let count = 0; - items.forEach((item) => { - if (Object.entries(selection).every( - ([key, value]) => typeof item[key] === 'number' - && item[key] as number >= value[0] - && item[key] as number <= value[1], - )) { - count++; - } - }); - return count; + const selection = get(selectedElementSelector); + + if (!selection) return 0; + + return filterItems(items, selection).length; }, }); diff --git a/packages/upset/src/components/ElementView/BookmarkChips.tsx b/packages/upset/src/components/ElementView/BookmarkChips.tsx new file mode 100644 index 00000000..6eb62a72 --- /dev/null +++ b/packages/upset/src/components/ElementView/BookmarkChips.tsx @@ -0,0 +1,156 @@ +import SquareIcon from '@mui/icons-material/Square'; +import StarIcon from '@mui/icons-material/Star'; +import StarBorderIcon from '@mui/icons-material/StarBorder'; +import { Chip, Stack } from '@mui/material'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { + Bookmark, BookmarkedIntersection, flattenedOnlyRows, isBookmarkedIntersection, + isElementSelection, +} from '@visdesignlab/upset2-core'; +import { + bookmarkedColorPalette, + bookmarkSelector, + currentIntersectionSelector, + elementColorSelector, + nextColorSelector, +} from '../../atoms/config/currentIntersectionAtom'; +import { ProvenanceContext } from '../Root'; +import { dataAtom } from '../../atoms/dataAtom'; +import { UpsetActions, UpsetProvenance } from '../../provenance'; +import { selectedElementSelector } from '../../atoms/elementsSelectors'; + +/** + * Shows a stack of chips representing bookmarks and the current intersection/element selection, + * with options to add and remove bookmarks + */ +export const BookmarkChips = () => { + const { provenance, actions }: {provenance: UpsetProvenance, actions: UpsetActions} = useContext(ProvenanceContext); + const currentIntersection = useRecoilValue(currentIntersectionSelector); + const colorPallete = useRecoilValue(bookmarkedColorPalette); + const nextColor = useRecoilValue(nextColorSelector); + const data = useRecoilValue(dataAtom); + const rows = flattenedOnlyRows(data, provenance.getState()); + const bookmarked = useRecoilValue(bookmarkSelector); + const currentIntersectionDisplayName = currentIntersection?.elementName.replaceAll('~&~', ' & ') || ''; + const currentSelection = useRecoilValue(selectedElementSelector); + const elementSelectionColor = useRecoilValue(elementColorSelector); + + /** + * Handles when a chip in the bookmark stack is clicked + * @param bookmark Clicked bookmark + */ + function chipClicked(bookmark: Bookmark) { + if (isBookmarkedIntersection(bookmark)) { + if (currentIntersection?.id === bookmark.id) actions.setSelected(null); + else actions.setSelected(rows[bookmark.id]); + } else if (isElementSelection(bookmark)) { + // Need to update both the saved trrack state & the selection atom when a chip is clicked + if (currentSelection?.id === bookmark.id) actions.setElementSelection(null); + else actions.setElementSelection(bookmark); + } + } + + return ( + + {/* All chips from bookmarks */} + {bookmarked.map((bookmark) => ( + ({ + margin: theme.spacing(0.5), + '.MuiChip-icon': { + color: colorPallete[bookmark.id], + }, + backgroundColor: + bookmark.id === currentIntersection?.id || bookmark.id === currentSelection?.id + ? 'rgba(0,0,0,0.2)' + : 'default', + })} + key={bookmark.id} + aria-label={`Bookmarked intersection ${bookmark.label}${ + isBookmarkedIntersection(bookmark) ? `, size ${bookmark.size}` : ''}`} + onKeyDown={(e) => { + if (e.key === 'Enter') { + chipClicked(bookmark); + } + }} + label={isBookmarkedIntersection(bookmark) + ? `${bookmark.label} - ${bookmark.size}` : `${bookmark.label}`} + icon={} + deleteIcon={} + onClick={() => { + chipClicked(bookmark); + }} + onDelete={() => { + if (currentIntersection?.id === bookmark.id) { + actions.setSelected(null); + } else if (currentSelection?.id === bookmark.id) { + actions.setElementSelection(null); + } + actions.removeBookmark(bookmark); + }} + /> + ))} + {/* Chip for the currently selected intersection */} + {currentIntersection && !bookmarked.find((b) => b.id === currentIntersection.id) && ( + ({ + margin: theme.spacing(0.5), + '.MuiChip-icon': { + color: nextColor, + }, + backgroundColor: 'rgba(0,0,0,0.2)', + })} + icon={} + aria-label={`Selected intersection ${currentIntersectionDisplayName}, size ${currentIntersection.size}`} + onKeyDown={(e) => { + if (e.key === 'Enter') { + actions.addBookmark({ + id: currentIntersection.id, + label: currentIntersectionDisplayName, + size: currentIntersection.size, + type: 'intersection', + }); + } + }} + label={`${currentIntersectionDisplayName} - ${currentIntersection.size}`} + onDelete={() => { + actions.addBookmark({ + id: currentIntersection.id, + label: currentIntersectionDisplayName, + size: currentIntersection.size, + type: 'intersection', + }); + }} + deleteIcon={} + /> + )} + {/* Chip for the current element selection */} + {currentSelection && !bookmarked.find((b) => b.id === currentSelection.id) && ( + ({ + margin: theme.spacing(0.5), + '.MuiChip-icon': { + color: elementSelectionColor, + }, + backgroundColor: 'rgba(0,0,0,0.2)', + })} + icon={} + aria-label={`Selected elements ${currentSelection.label}`} + onKeyDown={(e) => { + if (e.key === 'Enter') { + actions.addBookmark(structuredClone(currentSelection)); + } + }} + label={`${currentSelection.label}`} + onDelete={() => { + actions.addBookmark(structuredClone(currentSelection)); + }} + deleteIcon={} + /> + )} + + ); +}; diff --git a/packages/upset/src/components/ElementView/ElementQueries.tsx b/packages/upset/src/components/ElementView/ElementQueries.tsx deleted file mode 100644 index d63f143b..00000000 --- a/packages/upset/src/components/ElementView/ElementQueries.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import SquareIcon from '@mui/icons-material/Square'; -import StarIcon from '@mui/icons-material/Star'; -import StarBorderIcon from '@mui/icons-material/StarBorder'; -import { Alert, Chip, Stack } from '@mui/material'; -import { useContext } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { - Bookmark, BookmarkedIntersection, BookmarkedSelection, flattenedOnlyRows, isBookmarkedIntersection, isBookmarkedSelection, -} from '@visdesignlab/upset2-core'; -import { - bookmarkedColorPalette, - bookmarkSelector, - currentIntersectionSelector, - elementColorSelector, - nextColorSelector, -} from '../../atoms/config/currentIntersectionAtom'; -import { ProvenanceContext } from '../Root'; -import { dataAtom } from '../../atoms/dataAtom'; -import { UpsetActions, UpsetProvenance } from '../../provenance'; -import { elementSelectionParameters, selectedElementSelector } from '../../atoms/elementsSelectors'; - -/** - * Shows a stack of chips representing bookmarks and the current intersection/element selection, - * with options to add and remove bookmarks - */ -export const ElementQueries = () => { - const { provenance, actions }: {provenance: UpsetProvenance, actions: UpsetActions} = useContext(ProvenanceContext); - const currentIntersection = useRecoilValue(currentIntersectionSelector); - const colorPallete = useRecoilValue(bookmarkedColorPalette); - const nextColor = useRecoilValue(nextColorSelector); - const data = useRecoilValue(dataAtom); - const rows = flattenedOnlyRows(data, provenance.getState()); - const bookmarked = useRecoilValue(bookmarkSelector); - const currentIntersectionDisplayName = currentIntersection?.elementName.replaceAll('~&~', ' & ') || ''; - const currentSelection = useRecoilValue(selectedElementSelector); - const savedSelection = useRecoilValue(selectedElementSelector); - const elementSelectionColor = useRecoilValue(elementColorSelector); - const elementSelection = useRecoilValue(elementSelectionParameters); - - /** - * Handles when a chip in the bookmark stack is clicked - * @param bookmark Clicked bookmark - */ - function chipClicked(bookmark: Bookmark) { - if (isBookmarkedIntersection(bookmark)) { - if (currentIntersection?.id === bookmark.id) actions.setSelected(null); - else actions.setSelected(rows[bookmark.id]); - } else if (isBookmarkedSelection(bookmark)) { - // Need to update both the saved trrack state & the selection atom when a chip is clicked - if (currentSelection?.id === bookmark.id && savedSelection !== null) actions.setElementSelection(null); - else if (savedSelection?.id !== bookmark.id) actions.setElementSelection(bookmark); - } - } - - return ( - <> - {!currentIntersection && bookmarked.length === 0 && ( - - Please click on intersections to select an intersection. - - )} - - {/* All chips from bookmarks */} - {bookmarked.map((bookmark) => ( - ({ - margin: theme.spacing(0.5), - '.MuiChip-icon': { - color: colorPallete[bookmark.id], - }, - backgroundColor: - bookmark.id === currentIntersection?.id || bookmark.id === currentSelection?.id - ? 'rgba(0,0,0,0.2)' - : 'default', - })} - key={bookmark.id} - aria-label={`Bookmarked intersection ${bookmark.label}${ - isBookmarkedIntersection(bookmark) ? `, size ${bookmark.size}` : ''}`} - onKeyDown={(e) => { - if (e.key === 'Enter') { - chipClicked(bookmark); - } - }} - label={isBookmarkedIntersection(bookmark) - ? `${bookmark.label} - ${bookmark.size}` : `${bookmark.label}`} - icon={} - deleteIcon={} - onClick={() => { - chipClicked(bookmark); - }} - onDelete={() => { - if (currentIntersection?.id === bookmark.id) { - actions.setSelected(null); - } - actions.removeBookmark({ id: bookmark.id, label: bookmark.label, type: bookmark.type }); - }} - /> - ))} - {/* Chip for the currently selected intersection */} - {currentIntersection && !bookmarked.find((b) => b.id === currentIntersection.id) && ( - ({ - margin: theme.spacing(0.5), - '.MuiChip-icon': { - color: nextColor, - }, - backgroundColor: 'rgba(0,0,0,0.2)', - })} - icon={} - aria-label={`Selected intersection ${currentIntersectionDisplayName}, size ${currentIntersection.size}`} - onKeyDown={(e) => { - if (e.key === 'Enter') { - actions.addBookmark({ - id: currentIntersection.id, - label: currentIntersectionDisplayName, - size: currentIntersection.size, - type: 'intersection', - }); - } - }} - label={`${currentIntersectionDisplayName} - ${currentIntersection.size}`} - onDelete={() => { - actions.addBookmark({ - id: currentIntersection.id, - label: currentIntersectionDisplayName, - size: currentIntersection.size, - type: 'intersection', - }); - }} - deleteIcon={} - /> - )} - {/* Chip for the current element selection */} - {elementSelection && currentSelection && !bookmarked.find((b) => b.id === currentSelection.id) && ( - ({ - margin: theme.spacing(0.5), - '.MuiChip-icon': { - color: elementSelectionColor, - }, - backgroundColor: 'rgba(0,0,0,0.2)', - })} - icon={} - aria-label={`Selected elements ${currentSelection.label}`} - onKeyDown={(e) => { - if (e.key === 'Enter') { - actions.addBookmark({ - id: currentSelection.id, - label: currentSelection.label, - type: 'elements', - selection: elementSelection, - }); - } - }} - label={`${currentSelection.label}`} - onDelete={() => { - actions.addBookmark({ - id: currentSelection.id, - label: currentSelection.label, - type: 'elements', - selection: elementSelection, - }); - }} - deleteIcon={} - /> - )} - - - ); -}; diff --git a/packages/upset/src/components/ElementView/ElementSidebar.tsx b/packages/upset/src/components/ElementView/ElementSidebar.tsx index 6d02bc6e..10e0ddd0 100644 --- a/packages/upset/src/components/ElementView/ElementSidebar.tsx +++ b/packages/upset/src/components/ElementView/ElementSidebar.tsx @@ -17,11 +17,12 @@ import { elementSelector, intersectionCountSelector, selectedElementSelector, selectedItemsCounter, selectedItemsSelector, } from '../../atoms/elementsSelectors'; -import { ElementQueries } from './ElementQueries'; +import { BookmarkChips } from './BookmarkChips'; import { ElementTable } from './ElementTable'; import { ElementVisualization } from './ElementVisualization'; import { UpsetActions } from '../../provenance'; import { ProvenanceContext } from '../Root'; +import { QueryInterface } from './QueryInterface'; /** * Props for the ElementSidebar component @@ -220,15 +221,20 @@ export const ElementSidebar = ({ open, close }: Props) => { - Element Queries + Bookmarked Queries - + Element Visualization + + Element Queries + + + Query Result { const currentIntersection = useRecoilValue(currentIntersectionSelector); const attributeColumns = useRecoilValue(attributeAtom); - const elementSelection = useRecoilValue(elementSelectionParameters); + const elementSelection = useRecoilValue(selectedElementSelector); const elements = elementSelection ? useRecoilValue(selectedItemsSelector) : useRecoilValue(elementSelector(currentIntersection?.id)); diff --git a/packages/upset/src/components/ElementView/ElementVisualization.tsx b/packages/upset/src/components/ElementView/ElementVisualization.tsx index 26a486ff..0ec47357 100644 --- a/packages/upset/src/components/ElementView/ElementVisualization.tsx +++ b/packages/upset/src/components/ElementView/ElementVisualization.tsx @@ -6,11 +6,11 @@ import { import { SignalListener, VegaLite } from 'react-vega'; import { useRecoilValue } from 'recoil'; -import { elementSelectionToBookmark, elementSelectionsEqual, isElementSelection } from '@visdesignlab/upset2-core'; -import { Button } from '@mui/material'; -import { bookmarkSelector, elementColorSelector } from '../../atoms/config/currentIntersectionAtom'; +import { numericalQueryToBookmark, numericalQueriesEqual, isNumericalQuery } from '@visdesignlab/upset2-core'; +import { Alert, Button } from '@mui/material'; +import { bookmarkSelector, currentIntersectionSelector, elementColorSelector } from '../../atoms/config/currentIntersectionAtom'; import { histogramSelector, scatterplotsSelector } from '../../atoms/config/plotAtoms'; -import { elementItemMapSelector, elementSelectionParameters } from '../../atoms/elementsSelectors'; +import { elementItemMapSelector, currentNumericalQuery } from '../../atoms/elementsSelectors'; import { AddPlotDialog } from './AddPlotDialog'; import { generateVega } from './generatePlotSpec'; import { ProvenanceContext } from '../Root'; @@ -30,18 +30,19 @@ export const ElementVisualization = () => { const histograms = useRecoilValue(histogramSelector); const bookmarked = useRecoilValue(bookmarkSelector); const items = useRecoilValue(elementItemMapSelector(bookmarked.map((b) => b.id))); - const elementSelection = useRecoilValue(elementSelectionParameters); + const numericalQuery = useRecoilValue(currentNumericalQuery); const selectColor = useRecoilValue(elementColorSelector); + const currentIntersection = useRecoilValue(currentIntersectionSelector); const { actions }: {actions: UpsetActions} = useContext(ProvenanceContext); /** * Internal State */ - const draftSelection = useRef(elementSelection); + const draftSelection = useRef(numericalQuery); const vegaSpec = useMemo( - () => generateVega(scatterplots, histograms, selectColor, elementSelection), - [scatterplots, histograms, selectColor, elementSelection], + () => generateVega(scatterplots, histograms, selectColor, numericalQuery), + [scatterplots, histograms, selectColor, numericalQuery], ); const data = useMemo(() => ({ elements: Object.values(structuredClone(items)), @@ -63,7 +64,7 @@ export const ElementVisualization = () => { * to an array of the bounds of the brush, but Vega's output format can change if the spec changes. */ const brushHandler: SignalListener = useCallback((_: string, value: unknown) => { - if (!isElementSelection(value)) return; + if (!isNumericalQuery(value)) return; draftSelection.current = value; }, [draftSelection]); @@ -78,15 +79,27 @@ export const ElementVisualization = () => { if ( draftSelection.current && Object.keys(draftSelection.current).length > 0 - && !elementSelectionsEqual(draftSelection.current, elementSelection) + && !numericalQueriesEqual(draftSelection.current, numericalQuery) ) { - actions.setElementSelection(elementSelectionToBookmark(draftSelection.current)); + actions.setElementSelection(numericalQueryToBookmark(draftSelection.current)); } else { actions.setElementSelection(null); } draftSelection.current = undefined; }} > + {!currentIntersection && bookmarked.length === 0 && ( + + Please click on an intersection to visualize its attributes. + + )} diff --git a/packages/upset/src/components/ElementView/QueryInterface.tsx b/packages/upset/src/components/ElementView/QueryInterface.tsx new file mode 100644 index 00000000..cda5b8ab --- /dev/null +++ b/packages/upset/src/components/ElementView/QueryInterface.tsx @@ -0,0 +1,129 @@ +import { + Button, + FormControl, InputLabel, MenuItem, Select, + TextField, +} from '@mui/material'; +import { Box } from '@mui/system'; +import { useRecoilValue } from 'recoil'; +import { ElementQueryToBookmark, ElementQueryType } from '@visdesignlab/upset2-core'; +import { + useCallback, useContext, useEffect, useState, +} from 'react'; +import { queryColumnsSelector } from '../../atoms/dataAtom'; +import { currentElementQuery } from '../../atoms/elementsSelectors'; +import { ProvenanceContext } from '../Root'; +import { UpsetActions } from '../../provenance'; + +/** + * Component showing a form allowing element queries to be created based on non-numeric fields + * @returns {EmotionJSX.Element} + */ +export const QueryInterface = () => { + /* + * State + */ + + const atts = useRecoilValue(queryColumnsSelector); + const currentSelection = useRecoilValue(currentElementQuery); + const { actions }: { actions: UpsetActions } = useContext(ProvenanceContext); + + const FIELD_MARGIN = '5px'; + const FIELD_CSS = { marginTop: FIELD_MARGIN, width: '50%' }; + + const [attField, setAttField] = useState(currentSelection?.att); + const [typeField, setTypeField] = useState(currentSelection?.type); + const [queryField, setQueryField] = useState(currentSelection?.query); + + // Resets input state every time the current selection changes + useEffect(() => { + if (currentSelection) { + setAttField(currentSelection?.att); + setTypeField(currentSelection?.type); + setQueryField(currentSelection?.query); + } else { + setAttField(undefined); + setTypeField(undefined); + setQueryField(undefined); + } + }, [currentSelection]); + + /* + * Functions + */ + + /** + * Save the current query if none is defined, or clear the existing selection + */ + const saveOrClear = useCallback(() => { + if (currentSelection) { + actions.setElementSelection(null); + setAttField(undefined); + setTypeField(undefined); + setQueryField(undefined); + } else if (attField && typeField && queryField + && Object.values(ElementQueryType).includes(typeField as ElementQueryType) + && atts.includes(attField) + ) { + actions.setElementSelection(ElementQueryToBookmark({ + att: attField, + type: typeField as ElementQueryType || ElementQueryType.EQUALS, + query: queryField, + })); + } + }, [attField, typeField, queryField, atts, actions, currentSelection]); + + return atts.length > 0 ? ( + + + Attribute Name + + + + Query Type + + + + setQueryField(e.target.value)} + /> + + + + ) : null; +}; diff --git a/packages/upset/src/components/ElementView/generatePlotSpec.ts b/packages/upset/src/components/ElementView/generatePlotSpec.ts index 36d3028d..9c5f0a4d 100644 --- a/packages/upset/src/components/ElementView/generatePlotSpec.ts +++ b/packages/upset/src/components/ElementView/generatePlotSpec.ts @@ -1,10 +1,10 @@ import { - ElementSelection, Histogram, isHistogram, isScatterplot, Plot, Scatterplot, + NumericalQuery, Histogram, isHistogram, isScatterplot, Plot, Scatterplot, } from '@visdesignlab/upset2-core'; import { VisualizationSpec } from 'react-vega'; /** - * Converts an elementselection to a value for a vega param. + * Converts an NumericalQuery to a value for a vega param. * Plots want x and y ranges instead of attribute ranges, so we need to convert the selection to match. * If this plot's axis don't match the selection attributes, we return undefined to avoid conflicting selections. * @param plot The plot that we need a selection value for @@ -12,8 +12,8 @@ import { VisualizationSpec } from 'react-vega'; * @returns An object which can be assigned to the 'value' field of a vega param in the plot * to display the selection in the plot. */ -function convertSelection(plot: Plot, select: ElementSelection): ElementSelection | undefined { - let val: ElementSelection | undefined; +function convertSelection(plot: Plot, select: NumericalQuery): NumericalQuery | undefined { + let val: NumericalQuery | undefined; if (isScatterplot(plot) && select[plot.x] && select[plot.y]) { val = { x: select[plot.x], @@ -81,7 +81,7 @@ export function createScatterplotSpec( */ export function createScatterplotRow( specs: Scatterplot[], - selection: ElementSelection | undefined, + selection: NumericalQuery | undefined, selectColor: string, ): VisualizationSpec[] { return specs.map((s) => ({ @@ -218,7 +218,7 @@ export function createHistogramSpec( */ export function createHistogramRow( histograms: Histogram[], - selection: ElementSelection | undefined, + selection: NumericalQuery | undefined, ) : VisualizationSpec[] { function makeParams(plot: Histogram) { @@ -346,7 +346,7 @@ export function generateVega( scatterplots: Scatterplot[], histograms: Histogram[], selectColor: string, - selection? : ElementSelection, + selection? : NumericalQuery, ): VisualizationSpec { // If we have an empty selection {}, we need to feed undefined to the specs, but !!{} is true const newSelection = selection && Object.keys(selection).length > 0 ? selection : undefined; diff --git a/packages/upset/src/provenance/index.ts b/packages/upset/src/provenance/index.ts index 70682dcb..d1654006 100644 --- a/packages/upset/src/provenance/index.ts +++ b/packages/upset/src/provenance/index.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { AggregateBy, Plot, PlotInformation, SortByOrder, SortVisibleBy, UpsetConfig, DefaultConfig, Row, - Bookmark, BookmarkedSelection, + Bookmark, convertConfig, ColumnName, AltText, + ElementSelection, } from '@visdesignlab/upset2-core'; import { Registry, StateChangeFunction, initializeTrrack } from '@trrack/core'; @@ -331,10 +332,10 @@ const setSelectedAction = register( }, ); -const setElementSelectionAction = register( +const setElementSelectionAction = register( 'select-elements', - (state: UpsetConfig, bookmarkedSelection) => { - state.elementSelection = bookmarkedSelection; + (state: UpsetConfig, elementSelection) => { + state.elementSelection = elementSelection; return state; }, ); @@ -440,7 +441,8 @@ export function getActions(provenance: UpsetProvenance) { * which is a filter on items based on their attributes. * @param selection The selection to set */ - setElementSelection: (selection: BookmarkedSelection | null) => provenance.apply( + setElementSelection: (selection: ElementSelection | null) => provenance.apply( + // Object.keys check is for numerical queries, which can come out of vega as {} selection && Object.keys(selection.selection).length > 0 ? `Selected elements based on the following keys: ${Object.keys(selection.selection).join(' ')}` : 'Deselected elements',