diff --git a/e2e-tests/plot.spec.ts b/e2e-tests/plot.spec.ts index d20fb573..ff474499 100644 --- a/e2e-tests/plot.spec.ts +++ b/e2e-tests/plot.spec.ts @@ -106,3 +106,39 @@ test('Size header', async ({ page }) => { await toggleAdvancedScale(page); await assertSizeScaleMax(page, 3); }); + +/** + * Tests that the attribute header menu items for changing plot type work and update on selection + * *Note* Does NOT test the actual plot rendering + */ +test('Attribute Plot Types', async ({ page }) => { + await page.goto('http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193'); + + // remove 'Male' set so that there are attributes with at least 6 items (threshold for dotplot) + await removeSetByName(page, 'Male'); + + const ageAttributeHeader = page.getByLabel('Age').locator('rect'); + + await ageAttributeHeader.click({ button: 'right', force: true }); + + // Click 'Change plot type to Dot Plot' + await page.getByRole('menuitem', { name: 'Change plot type to Dot Plot' }).click(); + await ageAttributeHeader.click({ button: 'right', force: true }); + + // Expect that dot plot is disabled + await expect(page.getByRole('menuitem', { name: 'Change plot type to Dot Plot' })).toBeDisabled(); + + // Click 'Change plot type to Strip Plot' + await page.getByRole('menuitem', { name: 'Change plot type to Strip Plot' }).click(); + await ageAttributeHeader.click({ button: 'right', force: true }); + + // Expect that strip plot is disabled + await expect(page.getByRole('menuitem', { name: 'Change plot type to Strip Plot' })).toBeDisabled(); + + // Click 'Change plot type to Density Plot' + await page.getByRole('menuitem', { name: 'Change plot type to Density Plot' }).click(); + await ageAttributeHeader.click({ button: 'right', force: true }); + + // Expect that density plot is disabled + await expect(page.getByRole('menuitem', { name: 'Change plot type to Density Plot' })).toBeDisabled(); +}); diff --git a/packages/core/src/defaultConfig.ts b/packages/core/src/defaultConfig.ts index 29389794..8e84fab1 100644 --- a/packages/core/src/defaultConfig.ts +++ b/packages/core/src/defaultConfig.ts @@ -1,4 +1,4 @@ -import { UpsetConfig } from './types'; +import { AttributePlotType, UpsetConfig } from './types'; export const DefaultConfig: UpsetConfig = { // Calls to the alttext API may error if these are not set @@ -25,6 +25,7 @@ export const DefaultConfig: UpsetConfig = { }, visibleSets: [], visibleAttributes: ['Degree', 'Deviation'], + attributePlots: {}, bookmarkedIntersections: [], collapsed: [], plots: { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9e7943ff..2579f3fd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -227,6 +227,25 @@ export type Histogram = BasePlot & { export type Plot = Scatterplot | Histogram; +/** + * Represents the different types of attribute plots. + * Enum value is used here so that the values can be used as keys in upset package. +*/ +// linter is saying this is already declared on line 226 (the line it is first declared...) +// eslint-disable-next-line no-shadow +export enum AttributePlotType { + BoxPlot = 'Box Plot', + DotPlot = 'Dot Plot', + StripPlot = 'Strip Plot', + DensityPlot = 'Density Plot', +} + +/** + * Represents the different types of attribute plots. + * Enum values (AttributePlotType) behave better in a Record object than in traditional dict types. + */ +export type AttributePlots = Record; + export type Bookmark = { id: string; label: string; size: number } /** @@ -273,6 +292,7 @@ export type UpsetConfig = { }; visibleSets: ColumnName[]; visibleAttributes: ColumnName[]; + attributePlots: AttributePlots; bookmarkedIntersections: Bookmark[]; collapsed: string[]; plots: { diff --git a/packages/upset/src/atoms/config/plotAtoms.ts b/packages/upset/src/atoms/config/plotAtoms.ts index 0d3ab62e..55bcfa5d 100644 --- a/packages/upset/src/atoms/config/plotAtoms.ts +++ b/packages/upset/src/atoms/config/plotAtoms.ts @@ -24,3 +24,8 @@ export const plotsSelector = selector({ ...get(histogramSelector), ], }); + +export const attributePlotsSelector = selector({ + key: 'attribute-plot', + get: ({ get }) => get(upsetConfigAtom).attributePlots, +}); diff --git a/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx b/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx index a5fa67ae..2303b6c4 100644 --- a/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx +++ b/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx @@ -1,17 +1,19 @@ import { Aggregate, SixNumberSummary, Items, Subset, isRowAggregate, } from '@visdesignlab/upset2-core'; -import { FC } from 'react'; +import React, { FC } from 'react'; import { useRecoilValue } from 'recoil'; - import { attributeMinMaxSelector } from '../../../atoms/attributeAtom'; import { dimensionsSelector } from '../../../atoms/dimensionsAtom'; import { useScale } from '../../../hooks/useScale'; import translate from '../../../utils/transform'; import { BoxPlot } from './AttributePlots/BoxPlot'; import { DotPlot } from './AttributePlots/DotPlot'; +import { StripPlot } from './AttributePlots/StripPlot'; +import { DensityPlot } from './AttributePlots/DensityPlot'; import { itemsAtom } from '../../../atoms/itemsAtoms'; import { DeviationBar } from '../DeviationBar'; +import { attributePlotsSelector } from '../../../atoms/config/plotAtoms'; /** * Attribute bar props @@ -32,6 +34,9 @@ type Props = { row: Subset | Aggregate; }; +// Threshold for when to render a dot plot regardless of selected plot type +const DOT_PLOT_THRESHOLD = 5; + const getValuesFromRow = (row: Subset | Aggregate, attribute: string, items: Items): number[] => { if (isRowAggregate(row)) { return Object.values(row.items.values).map((item) => getValuesFromRow(item, attribute, items)).flat(); @@ -48,17 +53,47 @@ export const AttributeBar: FC = ({ attribute, summary, row }) => { const items = useRecoilValue(itemsAtom); const values = getValuesFromRow(row, attribute, items); + const attributePlots = useRecoilValue(attributePlotsSelector); + if (typeof summary !== 'number' && (summary.max === undefined || summary.min === undefined || summary.first === undefined || summary.third === undefined || summary.median === undefined)) { return null; } + /* + * Get the attribute plot to render based on the selected attribute plot type + * @returns {JSX.Element} The JSX element of the attribute + */ + function getAttributePlotToRender(): React.JSX.Element { + // for every entry in attributePlotType, if the attribute matches the current attribute, return the corresponding plot + if (Object.keys(attributePlots).includes(attribute)) { + const plot = attributePlots[attribute]; + + // render a dotplot for all rows <= 5 + if (row.size <= DOT_PLOT_THRESHOLD) { + return ; + } + + switch (plot) { + case 'Box Plot': + return ; + case 'Strip Plot': + return ; + case 'Density Plot': + return ; + default: + return ; + } + } + return ; + } + return ( - { typeof summary === 'number' ? - : - row.size > 5 - ? - : } + { + typeof summary === 'number' ? + : + getAttributePlotToRender() + } ); }; diff --git a/packages/upset/src/components/Columns/Attribute/AttributePlots/DensityPlot.tsx b/packages/upset/src/components/Columns/Attribute/AttributePlots/DensityPlot.tsx new file mode 100644 index 00000000..032fcfec --- /dev/null +++ b/packages/upset/src/components/Columns/Attribute/AttributePlots/DensityPlot.tsx @@ -0,0 +1,83 @@ +import { VegaLite } from 'react-vega'; +import { Subset, Aggregate, AttributePlotType } from '@visdesignlab/upset2-core'; +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import { generateAttributePlotSpec } from './generateAttributePlotSpec'; +import { dimensionsSelector } from '../../../../atoms/dimensionsAtom'; +import { attributeMinMaxSelector } from '../../../../atoms/attributeAtom'; +import { + bookmarkedColorPalette, bookmarkedIntersectionSelector, currentIntersectionSelector, nextColorSelector, +} from '../../../../atoms/config/currentIntersectionAtom'; +import { ATTRIBUTE_DEFAULT_COLOR } from '../../../../utils/styles'; + +/** + * Props for the DotPlot component. + */ +type Props = { + /** + * Array of attribute values to plot. + */ + values: number[]; + /** + * The attribute name. + */ + attribute: string; + /** + * The row object. Rows can be either Subsets or Aggregates. + */ + row: Subset | Aggregate; +}; + +/** + * DensityPlot component displays a density plot for a given attribute. + * @param values - The values for the density plot. + * @param attribute - The attribute for which the density plot is displayed. + * @param row - The row for which the density plot is displayed. + */ +export const DensityPlot: FC = ({ + values, attribute, row, +}) => { + const dimensions = useRecoilValue(dimensionsSelector); + + const { min, max } = useRecoilValue(attributeMinMaxSelector(attribute)); + const currentIntersection = useRecoilValue(currentIntersectionSelector); + const bookmarks = useRecoilValue(bookmarkedIntersectionSelector); + const colorPalette = useRecoilValue(bookmarkedColorPalette); + const nextColor = useRecoilValue(nextColorSelector); + + /** + * Logic for determining the selection/bookmark status of the row. + * @returns {string} The fill color for the density plot. + */ + function getFillColor(): string { + // if the row is bookmarked, highlight the bar with the bookmark color + if (row !== undefined && bookmarks.some((b) => b.id === row.id)) { + // darken the color for advanced scale sub-bars + return colorPalette[row.id]; + } + + // We don't want to evaluate this to true if both currentIntersection and row are undefined, hence the 1st condition + if (currentIntersection && currentIntersection?.id === row?.id) { // if currently selected, use the highlight colors + return nextColor; + } + return ATTRIBUTE_DEFAULT_COLOR; + } + + const spec = generateAttributePlotSpec(AttributePlotType.DensityPlot, values, min, max, getFillColor()); + + return ( + + + + + + ); +}; diff --git a/packages/upset/src/components/Columns/Attribute/AttributePlots/DotPlot.tsx b/packages/upset/src/components/Columns/Attribute/AttributePlots/DotPlot.tsx index 6766b08b..bba2a3ba 100644 --- a/packages/upset/src/components/Columns/Attribute/AttributePlots/DotPlot.tsx +++ b/packages/upset/src/components/Columns/Attribute/AttributePlots/DotPlot.tsx @@ -1,28 +1,80 @@ import { FC } from 'react'; import { useRecoilValue } from 'recoil'; import { ScaleLinear } from 'd3-scale'; -import { Aggregate, SixNumberSummary, Subset } from '@visdesignlab/upset2-core'; +import { Aggregate, Subset } from '@visdesignlab/upset2-core'; import { dimensionsSelector } from '../../../../atoms/dimensionsAtom'; import { visibleAttributesSelector } from '../../../../atoms/config/visibleAttributes'; +/** + * Props for the DotPlot component. + */ type Props = { + /** + * The scale for mapping attribute values to x-axis positions. + */ scale: ScaleLinear; + /** + * Array of attribute values to plot. + */ values: number[]; + /** + * The attribute name. + */ attribute: string; - summary: SixNumberSummary; + /** + * Indicates whether the attribute is an aggregate. + */ isAggregate: boolean; + /** + * The row object. Rows can be either Subsets or Aggregates. + */ row: Subset | Aggregate; + /** + * Whether to jitter the dots + */ + jitter?: boolean; }; -// Dot plot component for the attributes plots +/** + * Renders a Dot Plot for a given attribute. + * + * @component + * @param {Props} props - The component props. + * @param {number} props.scale - The scale for mapping attribute values to x-axis positions. + * @param {number[]} props.values - The array of attribute values to plot. + * @param {string} props.attribute - The attribute name. + * @param {boolean} props.isAggregate - Indicates whether the row is an aggregate. + * @param {Row} props.row - The row object. Rows can be either Subsets or Aggregates. + * @param {boolean} props.jitter - Whether to jitter the dots. + * @returns {JSX.Element} The rendered dot plot. + */ export const DotPlot: FC = ({ - scale, values, attribute, summary, isAggregate, row, + scale, values, attribute, isAggregate, row, jitter = false, }) => { const dimensions = useRecoilValue(dimensionsSelector); const attributes = useRecoilValue(visibleAttributesSelector); - if (summary.max === undefined || summary.min === undefined || summary.first === undefined || summary.third === undefined || summary.median === undefined) { - return null; + /** + * Generates a y offset for the provided index. + * Seeded based on row size and length of row id so that jitter is consistent between renders, and also varies between rows. + * Rows of the same size AND same id string length will have the same jitter. + * @param index The index of the dot being rendered + * @returns y offset for the dot based on the index and row size + */ + function getJitterForIndex(index: number) { + const seed = row.size + row.id.length + index; + + /** + * Generates a random number between 0 and 1 using a seed value. + * Poor randomness approximation, but good enough for jittering. + * @returns A random number between 0 and 1. + */ + function random() { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); + } + + return (dimensions.attribute.plotHeight / 4) * (1 - random() * 2); } return ( @@ -36,8 +88,9 @@ export const DotPlot: FC = ({ y={-(dimensions.attribute.plotHeight / 2)} /> {values.map((value, idx) => ( + // There is no unique identifier for the attribute values other than index, so it is used as key // eslint-disable-next-line react/no-array-index-key - + ))} ); diff --git a/packages/upset/src/components/Columns/Attribute/AttributePlots/StripPlot.tsx b/packages/upset/src/components/Columns/Attribute/AttributePlots/StripPlot.tsx new file mode 100644 index 00000000..9341c52d --- /dev/null +++ b/packages/upset/src/components/Columns/Attribute/AttributePlots/StripPlot.tsx @@ -0,0 +1,79 @@ +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ScaleLinear } from 'd3-scale'; +import { Aggregate, Subset } from '@visdesignlab/upset2-core'; +import { dimensionsSelector } from '../../../../atoms/dimensionsAtom'; +import { visibleAttributesSelector } from '../../../../atoms/config/visibleAttributes'; + +/** + * Props for the StripPlot component. + */ +type Props = { + /** + * The scale for mapping attribute values to x-axis positions. + */ + scale: ScaleLinear; + /** + * Array of attribute values to plot. + */ + values: number[]; + /** + * The attribute name. + */ + attribute: string; + /** + * Indicates whether the attribute is an aggregate. + */ + isAggregate: boolean; + /** + * The row object. Rows can be either Subsets or Aggregates. + */ + row: Subset | Aggregate; +}; + +/** + * Renders a strip plot for a given attribute. + * + * @component + * @param {Props} props - The component props. + * @param {number} props.scale - The scale for mapping attribute values to x-axis positions. + * @param {number[]} props.values - The array of attribute values to plot. + * @param {string} props.attribute - The attribute name. + * @param {boolean} props.isAggregate - Indicates whether the row is an aggregate. + * @param {Row} props.row - The row object. Rows can be either Subsets or Aggregates. + * @returns {JSX.Element} The rendered strip plot. + */ +export const StripPlot: FC = ({ + scale, values, attribute, isAggregate, row, +}) => { + const dimensions = useRecoilValue(dimensionsSelector); + const attributes = useRecoilValue(visibleAttributesSelector); + + return ( + + + {values.map((value, idx) => ( + // vertical line for x position, go top to bottom + + ))} + + ); +}; diff --git a/packages/upset/src/components/Columns/Attribute/AttributePlots/generateAttributePlotSpec.ts b/packages/upset/src/components/Columns/Attribute/AttributePlots/generateAttributePlotSpec.ts new file mode 100644 index 00000000..cbea0394 --- /dev/null +++ b/packages/upset/src/components/Columns/Attribute/AttributePlots/generateAttributePlotSpec.ts @@ -0,0 +1,63 @@ +import { AttributePlotType } from '@visdesignlab/upset2-core'; +import { VisualizationSpec } from 'react-vega'; + +/** + * Generate the vega-lite specification for an attribute plot. + * @param {AttributePlotType} plotType - The type of plot to generate. Currently only density plots are supported. + * @param {number[]} values - The data points to plot. + * @param {number} min - The minimum value for the attribute range. + * @param {number} max - The maximum value for the attribute range. + * @param {stirng} plotColor - The fill color to use for the plot. + * @returns {VisualizationSpec | null} The vega-lite specification for the attribute plot. + */ +export function generateAttributePlotSpec( + plotType: AttributePlotType, + values: number[], + min: number, + max: number, + plotColor: string, +): VisualizationSpec | null { + // for every value provided, add it to an object with the key: density + // this will be used to generate the density plot + const data = { + values: values.map((value: any) => ({ density: value })), + }; + + if (plotType === AttributePlotType.DensityPlot) { + return { + height: 'container', + autosize: 'fit', + padding: 0, + background: 'transparent', + config: { + style: { + cell: { + stroke: 'transparent', + }, + }, + }, + data: { + values: data.values, + }, + mark: 'area', + transform: [ + { + density: 'density', extent: [min, max], + }, + ], + encoding: { + x: { + field: 'value', type: 'quantitative', axis: null, + }, + y: { + field: 'density', type: 'quantitative', axis: null, + }, + color: { + value: plotColor, + }, + }, + }; + } + + return null; +} diff --git a/packages/upset/src/components/Header/AttributeButton.tsx b/packages/upset/src/components/Header/AttributeButton.tsx index 76d67960..835b4aa8 100644 --- a/packages/upset/src/components/Header/AttributeButton.tsx +++ b/packages/upset/src/components/Header/AttributeButton.tsx @@ -1,6 +1,6 @@ import { FC, useContext } from 'react'; import { useSetRecoilState, useRecoilValue } from 'recoil'; -import { SortByOrder } from '@visdesignlab/upset2-core'; +import { SortByOrder, AttributePlotType } from '@visdesignlab/upset2-core'; import { Tooltip } from '@mui/material'; import { dimensionsSelector } from '../../atoms/dimensionsAtom'; @@ -11,6 +11,7 @@ import { contextMenuAtom } from '../../atoms/contextMenuAtom'; import { HeaderSortArrow } from '../custom/HeaderSortArrow'; import { ContextMenuItem } from '../../types'; import { allowAttributeRemovalAtom } from '../../atoms/config/allowAttributeRemovalAtom'; +import { attributePlotsSelector } from '../../atoms/config/plotAtoms'; /** @jsxImportSource @emotion/react */ type Props = { @@ -38,6 +39,8 @@ export const AttributeButton: FC = ({ label }) => { const sortByOrder = useRecoilValue(sortByOrderSelector); const setContextMenu = useSetRecoilState(contextMenuAtom); + const attributePlots = useRecoilValue(attributePlotsSelector); + const allowAttributeRemoval = useRecoilValue(allowAttributeRemovalAtom); /** @@ -94,6 +97,22 @@ export const AttributeButton: FC = ({ label }) => { }, ]; + if (!['Degree', 'Deviation'].includes(label)) { + // for every possible value of the type AttributePlotType (from core), add a menu item + Object.values(AttributePlotType).forEach((plot) => { + items.push( + { + label: `Change plot type to ${plot}`, + onClick: () => { + actions.updateAttributePlotType(label, plot); + handleContextMenuClose(); + }, + disabled: attributePlots[label] === plot, + }, + ); + }); + } + // Add remove attribute option if allowed if (allowAttributeRemoval) { items.push( diff --git a/packages/upset/src/components/Upset.tsx b/packages/upset/src/components/Upset.tsx index df159850..7f7560cc 100644 --- a/packages/upset/src/components/Upset.tsx +++ b/packages/upset/src/components/Upset.tsx @@ -83,6 +83,13 @@ export const Upset: FC = ({ ]; } + // for every visible attribute other than deviaiton and degree, set their initial attribute plot type to 'Box Plot' + conf.visibleAttributes.forEach((attr) => { + if (attr !== 'Degree' && attr !== 'Deviation' && !conf.attributePlots[attr]) { + conf.attributePlots = { ...conf.attributePlots, [attr]: 'Box Plot' }; + } + }); + return conf; }, [config]); diff --git a/packages/upset/src/provenance/index.ts b/packages/upset/src/provenance/index.ts index 02280a4d..2a70d041 100644 --- a/packages/upset/src/provenance/index.ts +++ b/packages/upset/src/provenance/index.ts @@ -153,6 +153,14 @@ const removeMultipleVisibleAttributes = registry.register( }, ); +const updateAttributePlotType = registry.register( + 'update-attribute-plot-type', + (state: UpsetConfig, { attr, plotType }) => { + state.attributePlots[attr] = plotType; + return state; + }, +); + const bookmarkIntersectionAction = registry.register( 'bookmark-intersection', (state: UpsetConfig, newBookmark) => { @@ -362,6 +370,7 @@ export function getActions(provenance: UpsetProvenance) { removeAttribute: (attr: string) => provenance.apply(`Hide ${attr}`, removeFromVisibleAttributes(attr)), addMultipleAttributes: (attrs: string[]) => provenance.apply(`Show ${attrs.length} attributes`, addMultipleVisibleAttributes(attrs)), removeMultipleVisibleAttributes: (attrs: string[]) => provenance.apply(`Hide ${attrs.length} attributes`, removeMultipleVisibleAttributes(attrs)), + updateAttributePlotType: (attr: string, plotType: string) => provenance.apply(`Update ${attr} plot type to ${plotType}`, updateAttributePlotType({ attr, plotType })), bookmarkIntersection: (id: string, label: string, size: number) => provenance.apply(`Bookmark ${label}`, bookmarkIntersectionAction({ id, label, size })), unBookmarkIntersection: (id: string, label: string, size: number) => provenance.apply(`Unbookmark ${label}`, removeBookmarkIntersectionAction({ id, label, size })), addPlot: (plot: Plot) => provenance.apply(`Add Plot: ${plot.type}`, addPlotAction(plot)), diff --git a/packages/upset/src/utils/styles.ts b/packages/upset/src/utils/styles.ts index c6499525..2b99b8cb 100644 --- a/packages/upset/src/utils/styles.ts +++ b/packages/upset/src/utils/styles.ts @@ -43,4 +43,6 @@ export const mousePointer = css` export const arrowIconCSS = { height: '16px', width: '16px', marginLeft: '6px', -}; \ No newline at end of file +}; + +export const ATTRIBUTE_DEFAULT_COLOR = '#d3d3d3';