Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Additional attribute plot types (dot, strip, density) #366

Merged
merged 9 commits into from
Aug 6, 2024
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 = {
plotInformation: {
Expand All @@ -22,6 +22,7 @@ export const DefaultConfig: UpsetConfig = {
},
visibleSets: [],
visibleAttributes: ['Degree', 'Deviation'],
attributePlots: {},
bookmarkedIntersections: [],
collapsed: [],
plots: {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,24 @@ 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',
}

/**
* 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}`>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document type. It wasn't clear to me until reading the rest of the code why this needs to be a Record, ie, what does the string represent?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Record here is simply a more concise way of doing this notation:

type AttributePlots = {
  [attribute: string]: keyof typeof AttributePlotType
}

Enums don't behave super well in typescript as opposed to languages like Java, IMO. I'll note that in the documentation. I think moving forward, we should try to use Record as it is more clear and easier to read.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, my original comment wasn't clear: I think you should note that the string is the name of the attribute. So this record maps attribute names to their plot types.


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

export type UpsetConfig = {
Expand All @@ -237,6 +255,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,
});
46 changes: 39 additions & 7 deletions packages/upset/src/components/Columns/Attribute/AttributeBar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
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 { itemsAtom } from '../../../atoms/itemsAtoms';
import { DeviationBar } from '../DeviationBar';
import { attributePlotsSelector } from '../../../atoms/config/plotAtoms';

/**
* Attribute bar props
Expand All @@ -32,6 +33,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 +52,45 @@ 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} />;
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
@@ -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
Original file line number Diff line number Diff line change
@@ -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<number, number, never>;
/**
* 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<Props> = ({
scale, values, attribute, isAggregate, row,
}) => {
const dimensions = useRecoilValue(dimensionsSelector);
const attributes = useRecoilValue(visibleAttributesSelector);

return (
<g>
<rect
fill="#ccc"
opacity={attributes.indexOf(attribute) % 2 === 1 ? 0.0 : (isAggregate ? 0.4 : 0.2)}
width={dimensions.attribute.width + dimensions.attribute.dotSize * 2}
height={dimensions.attribute.plotHeight}
x={-(dimensions.attribute.dotSize)}
y={-(dimensions.attribute.plotHeight / 2)}
/>
{values.map((value, idx) => (
// vertical line for x position, go top to bottom
<line
// 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
key={`${row.id} + ${idx}`}
x1={scale(value)}
x2={scale(value)}
y1={-(dimensions.attribute.plotHeight / 2)}
y2={dimensions.attribute.plotHeight / 2}
stroke="#474242"
opacity={0.3}
strokeWidth={dimensions.attribute.dotSize / 3}
/>
))}
</g>
);
};
21 changes: 20 additions & 1 deletion packages/upset/src/components/Header/AttributeButton.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = {
Expand Down Expand Up @@ -38,6 +39,8 @@ export const AttributeButton: FC<Props> = ({ label }) => {
const sortByOrder = useRecoilValue(sortByOrderSelector);
const setContextMenu = useSetRecoilState(contextMenuAtom);

const attributePlots = useRecoilValue(attributePlotsSelector);

const allowAttributeRemoval = useRecoilValue(allowAttributeRemovalAtom);

/**
Expand Down Expand Up @@ -94,6 +97,22 @@ export const AttributeButton: FC<Props> = ({ 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(
Expand Down
7 changes: 7 additions & 0 deletions packages/upset/src/components/Upset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export const Upset: FC<UpsetProps> = ({
];
}

// for every visible attribute other than deviaiton and degree, add 'Box Plot' to their attribute plot type
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// for every visible attribute other than deviaiton and degree, add 'Box Plot' to their attribute plot type
// 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]);

Expand Down
Loading
Loading