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
10 changes: 10 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ export type Histogram = BasePlot & {

export type Plot = Scatterplot | Histogram;

// eslint-disable-next-line no-shadow
export enum AttributePlotType {
BoxPlot = 'Box Plot',
DotPlot = 'Dot Plot',
StripPlot = 'Strip Plot',
}

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 +246,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,
});
43 changes: 38 additions & 5 deletions packages/upset/src/components/Columns/Attribute/AttributeBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ 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 Down Expand Up @@ -48,17 +50,48 @@ 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() {
// for every entry in attributePlotType, if the attribute matches the current attribute, return the corresponding plot
// eslint-disable-next-line consistent-return
if (Object.keys(attributePlots).includes(attribute)) {
const [attr, plot] = Object.entries(attributePlots).filter(([key, _]) => key === attribute)[0];

if (attr === attribute) {
// render a dotplot for all rows <= 5
if (row.size <= 5) {
return <DotPlot scale={scale} values={values} attribute={attribute} summary={summary as SixNumberSummary} 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} summary={summary as SixNumberSummary} isAggregate={isRowAggregate(row)} row={row} />;
default:
return <DotPlot scale={scale} values={values} attribute={attribute} summary={summary as SixNumberSummary} 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
Expand Up @@ -12,11 +12,12 @@ type Props = {
summary: SixNumberSummary;
isAggregate: boolean;
row: Subset | Aggregate;
jitter?: boolean;
};

// Dot plot component for the attributes plots
export const DotPlot: FC<Props> = ({
scale, values, attribute, summary, isAggregate, row,
scale, values, attribute, summary, isAggregate, row, jitter = false,
}) => {
const dimensions = useRecoilValue(dimensionsSelector);
const attributes = useRecoilValue(visibleAttributesSelector);
Expand All @@ -25,6 +26,29 @@ export const DotPlot: FC<Props> = ({
return null;
}

/**
* Generates a y offset for the provided index.
* Seeded based on row size so that jitter is consistent between renders, and also varies between rows.
* Rows of the same size will have the same jitter.
Copy link
Contributor

Choose a reason for hiding this comment

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

Have you considered seeding it based on the row ID rather than size so that rows are always different?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a consideration, but since row_id is a string, it does get a bit more complicated. Maybe a decent way of further differentiating the seeding is to combine the row size with the length of the id string? This would make it much less likely that two rows would have both values the same.

Copy link
Member Author

Choose a reason for hiding this comment

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

Using the row id as well, these two rows have the same size, but different jitter:

image

Copy link
Contributor

Choose a reason for hiding this comment

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

That looks pretty good. ATM I also have a util function in my backselection PR that can hash a string, but probably not necessary here.

* @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 + 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 (
<g>
<rect
Expand All @@ -37,7 +61,7 @@ export const DotPlot: FC<Props> = ({
/>
{values.map((value, idx) => (
// 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,57 @@
import { FC } from 'react';
import { useRecoilValue } from 'recoil';
import { ScaleLinear } from 'd3-scale';
import { Aggregate, SixNumberSummary, Subset } from '@visdesignlab/upset2-core';
import { dimensionsSelector } from '../../../../atoms/dimensionsAtom';
import { visibleAttributesSelector } from '../../../../atoms/config/visibleAttributes';

type Props = {
scale: ScaleLinear<number, number, never>;
values: number[];
attribute: string;
summary: SixNumberSummary;
isAggregate: boolean;
row: Subset | Aggregate;
};

// Dot plot component for the attributes plots
export const StripPlot: FC<Props> = ({
scale, values, attribute, summary, isAggregate, row,
}) => {
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;
}

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) => (
// 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" />

// vertical line for x position, go top to bottom
<line
// 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
9 changes: 9 additions & 0 deletions packages/upset/src/provenance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,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) => {
Expand Down Expand Up @@ -337,6 +345,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)),
Expand Down
Loading