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
20 changes: 20 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,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}`>;
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 +256,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
Loading