Skip to content

Commit

Permalink
Merge pull request #366 from visdesignlab/249-alternative-attribute-p…
Browse files Browse the repository at this point in the history
…lots

Additional attribute plot types (dot, strip, density)
  • Loading branch information
JakeWags authored Aug 6, 2024
2 parents eb1218b + d6a230d commit 09fc29c
Show file tree
Hide file tree
Showing 13 changed files with 429 additions and 17 deletions.
36 changes: 36 additions & 0 deletions e2e-tests/plot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
3 changes: 2 additions & 1 deletion packages/core/src/defaultConfig.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +25,7 @@ export const DefaultConfig: UpsetConfig = {
},
visibleSets: [],
visibleAttributes: ['Degree', 'Deviation'],
attributePlots: {},
bookmarkedIntersections: [],
collapsed: [],
plots: {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, `${AttributePlotType}`>;

export type Bookmark = { id: string; label: string; size: number }

/**
Expand Down Expand Up @@ -273,6 +292,7 @@ export type UpsetConfig = {
};
visibleSets: ColumnName[];
visibleAttributes: ColumnName[];
attributePlots: AttributePlots;
bookmarkedIntersections: Bookmark[];
collapsed: string[];
plots: {
Expand Down
5 changes: 5 additions & 0 deletions packages/upset/src/atoms/config/plotAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ export const plotsSelector = selector({
...get(histogramSelector),
],
});

export const attributePlotsSelector = selector({
key: 'attribute-plot',
get: ({ get }) => get(upsetConfigAtom).attributePlots,
});
49 changes: 42 additions & 7 deletions packages/upset/src/components/Columns/Attribute/AttributeBar.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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();
Expand All @@ -48,17 +53,47 @@ export const AttributeBar: FC<Props> = ({ 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 <DotPlot scale={scale} values={values} attribute={attribute} isAggregate={isRowAggregate(row)} row={row} />;
}

switch (plot) {
case 'Box Plot':
return <BoxPlot scale={scale} summary={summary as SixNumberSummary} />;
case 'Strip Plot':
return <StripPlot scale={scale} values={values} attribute={attribute} isAggregate={isRowAggregate(row)} row={row} />;
case 'Density Plot':
return <DensityPlot values={values} attribute={attribute} row={row} />;
default:
return <DotPlot scale={scale} values={values} attribute={attribute} isAggregate={isRowAggregate(row)} row={row} jitter />;
}
}
return <BoxPlot scale={scale} summary={summary as SixNumberSummary} />;
}

return (
<g transform={translate(0, dimensions.attribute.plotHeight / 2)}>
{ typeof summary === 'number' ?
<DeviationBar deviation={summary} /> :
row.size > 5
? <BoxPlot scale={scale} summary={summary} />
: <DotPlot scale={scale} values={values} attribute={attribute} summary={summary} isAggregate={isRowAggregate(row)} row={row} />}
{
typeof summary === 'number' ?
<DeviationBar deviation={summary} /> :
getAttributePlotToRender()
}
</g>
);
};
Original file line number Diff line number Diff line change
@@ -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<Props> = ({
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 (
<g
id="Density"
transform={`translate(0, ${-dimensions.attribute.plotHeight / 1.5})`}
>
<foreignObject width={dimensions.attribute.width} height={dimensions.attribute.plotHeight + 20}>
<VegaLite
renderer="svg"
height={dimensions.attribute.plotHeight + 6}
actions={false}
spec={spec as any}
/>
</foreignObject>
</g>
);
};
Original file line number Diff line number Diff line change
@@ -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<number, number, never>;
/**
* 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<Props> = ({
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 (
Expand All @@ -36,8 +88,9 @@ export const DotPlot: FC<Props> = ({
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
<circle key={`${row.id} + ${idx}`} cx={scale(value)} cy={0} r={dimensions.attribute.dotSize} fill="black" opacity="0.4" />
<circle key={`${row.id} + ${idx}`} cx={scale(value)} cy={jitter ? getJitterForIndex(idx) : 0} r={dimensions.attribute.dotSize} fill="black" opacity="0.2" />
))}
</g>
);
Expand Down
Loading

0 comments on commit 09fc29c

Please sign in to comment.