Skip to content

Commit

Permalink
feat(vis-violin): violin vis subcategories and proper facets (#331)
Browse files Browse the repository at this point in the history
* fixed hexbin plot

* unify plotaxis

* fixed lasso

* hexbinplot: refactor labels, colors, axis

rafactor axis label color, position
refactor tick label, color
hide border
refactor selection colors (gray when not selected)

* moved useZoom

* changed opacity scale

* introduce color and size variables

* align axis / labels / legends / icons of heatmap

* small heatmap improvements

* selection colors

* fix label

* remove raincloud plot

* use selection and grid colors

* use correct color

* added legend to bar chart

* fix sort icons in barchart

* barchart: grid color

* use rem everywhere

* add sort icon component

* add priority sort, fix spacing, change default sort of heatmap to none

* rotat categorical x-labels when too long in barchart

* rotate labels

* fix barchart axis rotation

* fix x-label overflow

* show error message if vis type is not selected

* fix initial label cut off in barchart

* hide grid lines in bar chart

* fix barchart sorting

* align colors across vis types

* unify unknown labels

* fix legend height

* update imports and delete constants.tsx

* manage hexbin selection colors

* barchart: always consider legend height

* unify behaviour of selections

* rename variable name

* use one color scale for hexbin plot

* implement proper grouping and strip overlay

* fix: heatmap hover

* fix: selecting the same vis type twice

* remove test vis type from main

* change null label to unknown

* improve violin style

* improve selection

* hexbin plot: assign gray color to undefined or null values

* add possibility to sync yaxis

* add possibility to truncate from the middle

* barchart: assign gray color to undefined or null values; fix tooltip labels if undefined

* add sort logic

* assign default color to unknown categories

* hexbin plot: deactivate legend interactiknos

* add legend group title

* add category name to hoverinfo

* refactor: reuse existing selection color variable

* docs: add comments to constants

* refactor: cast to string

* some layout improvements

* add breast cancer dataset

* refactor: remove orphan code

* refactor: cast to string

* docs: convert comment to function comment

* fix: remove unused mantine theme from sankey vis

* sort icon and sort logic improvements

* small sorting fix

* add generic plotly sort icon create function

* apply review feedback

* refactor: remove groupedIds

* add compact mode for sort icon

* add sort icon for xaxis

* switch to breatCancer data set in demo app

* use neutral color for NAN_REPLACEMENT

* move files

* rename: I to identityMatrix

* rename I functions

* rename files

* some minor improvements

* add constant for traces color

- remove unused vectorv3 file

* reduce grid spacing

* fix pointpos

* do not add sub  category for y-value null

* set plotly legends to be enabled and disable legend click events

* add dummy violin halfs

* improve grid layout

* pass separate categoryOrder array per subplot

* add gene test data

* remove gene test data

* fix wrong axis type

* add check for undefined data

---------

Co-authored-by: Moritz Heckmann <[email protected]>
Co-authored-by: Daniela <[email protected]>
Co-authored-by: Christian Bors <[email protected]>
Co-authored-by: Holger Stitz <[email protected]>
  • Loading branch information
5 people committed Jun 14, 2024
1 parent 2cd6cff commit d429dc2
Show file tree
Hide file tree
Showing 15 changed files with 61,845 additions and 366 deletions.
33 changes: 9 additions & 24 deletions src/demo/MainApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,16 @@ import {
IScatterConfig,
Vis,
} from '../vis';
import { fetchIrisData } from '../vis/stories/fetchIrisData';
import { iris } from '../vis/stories/irisData';
import { breastCancerData } from '../vis/stories/breastCancerData';
import { fetchBreastCancerData } from '../vis/stories/fetchBreastCancerData';
import { MyCategoricalScore, MyLinkScore, MyNumberScore, MySMILESScore, MyStringScore } from './scoresUtils';

export function MainApp() {
const { user } = useVisynAppContext();
const [visConfig, setVisConfig] = React.useState<BaseVisConfig>({
type: ESupportedPlotlyVis.SCATTER,
numColumnsSelected: [
{
description: '',
id: 'sepalLength',
name: 'Sepal Length',
},
{
description: '',
id: 'sepalWidth',
name: 'Sepal Width',
},
],
color: {
description: '',
id: 'species',
name: 'Species',
},
numColumnsSelected: [],
color: null,
numColorScaleType: ENumericalColorScaleType.SEQUENTIAL,
shape: null,
dragMode: EScatterSelectSettings.RECTANGLE,
Expand All @@ -49,10 +34,10 @@ export function MainApp() {
showStats: true,
},
} as IScatterConfig);
const columns = React.useMemo(() => (user ? fetchIrisData() : []), [user]);
const [selection, setSelection] = React.useState<typeof iris>([]);
const columns = React.useMemo(() => (user ? fetchBreastCancerData() : []), [user]);
const [selection, setSelection] = React.useState<typeof breastCancerData>([]);

const visSelection = React.useMemo(() => selection.map((s) => `${iris.indexOf(s)}`), [selection]);
const visSelection = React.useMemo(() => selection.map((s) => `${breastCancerData.indexOf(s)}`), [selection]);
const [loading, setLoading] = React.useState(false);
const lineupRef = React.useRef<DatavisynTaggle>();

Expand Down Expand Up @@ -111,7 +96,7 @@ export function MainApp() {
/>

<VisynRanking
data={iris}
data={breastCancerData}
selection={selection}
setSelection={setSelection}
getBuilder={({ data }) => defaultBuilder({ data, smilesOptions: { setDynamicHeight: true } })}
Expand All @@ -130,7 +115,7 @@ export function MainApp() {
selected={visSelection}
selectionCallback={(s) => {
if (s) {
setSelection(s.map((i) => iris[+i]));
setSelection(s.map((i) => breastCancerData[+i]));
}
}}
filterCallback={(f) => {
Expand Down
64 changes: 61 additions & 3 deletions src/vis/general/SortIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as React from 'react';
import { Tooltip, ActionIcon, Text, Group } from '@mantine/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { VIS_LABEL_COLOR } from './constants';
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
import * as d3v7 from 'd3v7';
import * as React from 'react';
import { dvSort, dvSortAsc, dvSortDesc } from '../../icons';
import { selectionColorDark } from '../../utils';
import { VIS_LABEL_COLOR } from './constants';

export enum ESortStates {
NONE = 'none',
Expand Down Expand Up @@ -53,3 +54,60 @@ export function SortIcon({
</Group>
);
}

export function createPlotlySortIcon({
sortState,
axis,
axisLabel,
onToggleSort,
}: {
sortState: { col: string; state: ESortStates };
axis: string;
axisLabel: string;
onToggleSort: (col: string) => void;
}) {
const icon = sortState?.col === axisLabel ? (sortState.state === ESortStates.ASC ? dvSortAsc.icon[4] : dvSortDesc.icon[4]) : dvSort.icon[4];
const color = sortState?.col === axisLabel ? selectionColorDark : VIS_LABEL_COLOR;

const isYAxis = axis.includes('y');
const titleElement = d3v7.select(`g .${axis}title`);

if (titleElement.node()) {
// @ts-ignore
const bounds = titleElement.node().getBoundingClientRect();
const yOffset = isYAxis ? bounds.height / 2 + 30 : bounds.height - 5;
const xOffset = isYAxis ? bounds.width / 2 : -(bounds.width / 2 + 15);
const y = Number.parseInt(titleElement.attr('y'), 10) - yOffset;
const x = Number.parseInt(titleElement.attr('x'), 10) - xOffset;
if (d3v7.select(`g .g-${axis}title`).select('svg').empty()) {
const title = d3v7.select(`g .g-${axis}title`);

title
.style('pointer-events', 'all')
.append('svg')
.attr('width', 14)
.attr('height', 16)
.attr('x', x)
.attr('y', y)
.attr('viewBox', '0 0 512 472')
.attr('xmlns', 'http://www.w3.org/2000/svg')
.attr('fill', color)
.append('path')
.style('stroke-width', '1')
.attr('d', icon)
.attr('transform', `${isYAxis ? 'rotate(-90, 256, 236)' : ''}`);

title
.append('foreignObject')
.attr('width', 16)
.attr('height', 18)
.attr('x', x)
.attr('y', y)
.html(`<div></div>`)
.style('cursor', 'pointer')
.on('click', () => {
onToggleSort(axisLabel);
});
}
}
}
5 changes: 5 additions & 0 deletions src/vis/general/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ export const VIS_GRID_COLOR = '#E9ECEF';
*/
export const VIS_NEUTRAL_COLOR = '#71787E';

/**
* Trace color (e.g., Dot labels in scatter plot)
*/
export const VIS_TRACES_COLOR = '#7f7f7f';

/**
* Color for unselected items. It is the VIS_NEUTRAL_COLOR but with 0.3 opacity.
*/
Expand Down
91 changes: 69 additions & 22 deletions src/vis/general/layoutUtils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,39 @@
import { ColumnInfo, PlotlyInfo, VisColumn } from '../interfaces';
import { PlotlyTypes } from '../../plotly';
import { VIS_AXIS_LABEL_SIZE, VIS_AXIS_LABEL_SIZE_SMALL, VIS_GRID_COLOR, VIS_LABEL_COLOR, VIS_TICK_LABEL_SIZE, VIS_TICK_LABEL_SIZE_SMALL } from './constants';
import { ColumnInfo, PlotlyInfo, VisColumn } from '../interfaces';
import {
VIS_AXIS_LABEL_SIZE,
VIS_AXIS_LABEL_SIZE_SMALL,
VIS_GRID_COLOR,
VIS_LABEL_COLOR,
VIS_TICK_LABEL_SIZE,
VIS_TICK_LABEL_SIZE_SMALL,
VIS_TRACES_COLOR,
} from './constants';

/**
*
* @param alpha Alpha value from 0-1 to convert to hex representation
* @returns Hex representation of the given alpha value
*/
export const alphaToHex = (alpha: number) => {
const alphaInt = Math.round(alpha * 255);
const alphaHex = alphaInt.toString(16).toUpperCase();
return alphaHex.padStart(2, '0');
};

/**
* Truncate long texts (e.g., to use as axes title)
* @param text Input text to be truncated
* @param middle If true, truncate from the middle (default: false)
* @param maxLength Maximum text length (default: 50)
*/
export function truncateText(text: string, maxLength = 50) {
return text?.length > maxLength ? `${text.substring(0, maxLength)}\u2026` : text;
export function truncateText(text: string, middle: boolean = false, maxLength = 50) {
const half = maxLength / 2;
return text?.length > maxLength
? middle
? `${text.substring(0, half)}\u2026${text.substring(text.length - half)}`
: `${text.substring(0, maxLength)}\u2026`
: text;
}

export function columnNameWithDescription(col: ColumnInfo) {
Expand All @@ -21,9 +46,22 @@ export function columnNameWithDescription(col: ColumnInfo) {
* @param layout the current layout to be changed. Typed to any because the plotly types complain.p
* @returns the changed layout
*/
export function beautifyLayout(traces: PlotlyInfo, layout: Partial<PlotlyTypes.Layout>, oldLayout: Partial<PlotlyTypes.Layout>, automargin = true) {
export function beautifyLayout(
traces: PlotlyInfo,
layout: Partial<PlotlyTypes.Layout>,
oldLayout: Partial<PlotlyTypes.Layout>,
categoryOrder: Map<number, string[]> = null,
automargin = true,
autorange = true,
) {
layout.annotations = [];
const titlePlots = traces.plots.filter((value, index, self) => {

// Sometimes we have multiple traces that share the same axis. For layout changes we only need to consider one per axis.
const sharedAxisTraces = traces.plots.filter((value, index, self) => {
return self.findIndex((v) => v.data.xaxis === value.data.xaxis && v.data.yaxis === value.data.yaxis) === index;
});

const titleTraces = sharedAxisTraces.filter((value, index, self) => {
return value.title && self.findIndex((v) => v.title === value.title) === index;
});

Expand All @@ -34,73 +72,82 @@ export function beautifyLayout(traces: PlotlyInfo, layout: Partial<PlotlyTypes.L

// We should stop using plotly for a component like this one which wants a lot of unique functionality, and does not require complex rendering logic (like a canvas)

titlePlots.forEach((t) => {
titleTraces.forEach((t) => {
if (t.title) {
layout.annotations.push({
text: t.title,
text: truncateText(t.title, true, 30),
showarrow: false,
x: 0.5,
y: 1.1,
// @ts-ignore
xref: `${t.data.xaxis} domain`,
// @ts-ignore
yref: `${t.data.yaxis} domain`,
y: 1.0,
yshift: 5,
xref: `${t.data.xaxis} domain` as Plotly.XAxisName,
yref: `${t.data.yaxis} domain` as Plotly.YAxisName,
font: {
size: 13.4,
color: VIS_TRACES_COLOR,
},
});
}
});

traces.plots.forEach((t, i) => {
sharedAxisTraces.forEach((t, i) => {
const axisX = t.data.xaxis?.replace('x', 'xaxis') || 'xaxis';
layout[axisX] = {
range: t.xDomain ? t.xDomain : null,
...oldLayout?.[`xaxis${i > 0 ? i + 1 : ''}`],
range: t.xDomain ? t.xDomain : null,
color: VIS_LABEL_COLOR,
gridcolor: VIS_GRID_COLOR,
zerolinecolor: VIS_GRID_COLOR,
automargin,
tickvals: t.xTicks,
ticktext: t.xTickLabels,
tickfont: {
size: traces.plots.length > 1 ? VIS_TICK_LABEL_SIZE_SMALL : VIS_TICK_LABEL_SIZE,
size: sharedAxisTraces.length > 1 ? VIS_TICK_LABEL_SIZE_SMALL : VIS_TICK_LABEL_SIZE,
},
type: typeof t.data.x?.[0] === 'string' ? 'category' : null,
ticks: 'none',
text: t.xTicks,
showspikes: false,
spikedash: 'dash',
categoryarray: categoryOrder?.get(i + 1) || null,
categoryorder: categoryOrder?.get(i + 1) ? 'array' : null,

title: {
standoff: 5,
text: traces.plots.length > 1 ? truncateText(t.xLabel, 20) : truncateText(t.xLabel, 55),
text: sharedAxisTraces.length > 1 ? truncateText(t.xLabel, false, 20) : truncateText(t.xLabel, true, 55),
font: {
family: 'Roboto, sans-serif',
size: traces.plots.length > 1 ? VIS_AXIS_LABEL_SIZE_SMALL : VIS_AXIS_LABEL_SIZE,
size: sharedAxisTraces.length > 1 ? VIS_AXIS_LABEL_SIZE_SMALL : VIS_AXIS_LABEL_SIZE,
color: VIS_LABEL_COLOR,
},
},
};

const axisY = t.data.yaxis?.replace('y', 'yaxis') || 'yaxis';
layout[axisY] = {
range: t.yDomain ? t.yDomain : null,
...oldLayout?.[`yaxis${i > 0 ? i + 1 : ''}`],
range: t.yDomain ? t.yDomain : null,
automargin,
autorange,
color: VIS_LABEL_COLOR,
gridcolor: VIS_GRID_COLOR,
zerolinecolor: VIS_GRID_COLOR,
tickvals: t.yTicks,
ticktext: t.yTickLabels,
tickfont: {
size: traces.plots.length > 1 ? VIS_TICK_LABEL_SIZE_SMALL : VIS_TICK_LABEL_SIZE,
size: sharedAxisTraces.length > 1 ? VIS_TICK_LABEL_SIZE_SMALL : VIS_TICK_LABEL_SIZE,
},
type: typeof t.data.y?.[0] === 'string' ? 'category' : null,
ticks: 'none',
text: t.yTicks,
showspikes: false,
spikedash: 'dash',
title: {
standoff: 5,
text: traces.plots.length > 1 ? truncateText(t.yLabel, 20) : truncateText(t.yLabel, 55),
text: sharedAxisTraces.length > 1 ? truncateText(t.yLabel, false, 20) : truncateText(t.yLabel, true, 55),
font: {
family: 'Roboto, sans-serif',
size: traces.plots.length > 1 ? VIS_AXIS_LABEL_SIZE_SMALL : VIS_AXIS_LABEL_SIZE,
size: sharedAxisTraces.length > 1 ? VIS_AXIS_LABEL_SIZE_SMALL : VIS_AXIS_LABEL_SIZE,
color: VIS_LABEL_COLOR,
weight: 'bold',
},
Expand Down
1 change: 1 addition & 0 deletions src/vis/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface VisNumericalColumn extends VisCommonColumn {
export interface VisCategoricalColumn extends VisCommonColumn {
type: EColumnTypes.CATEGORICAL;
color?: Record<string, string>;
domain?: string[];
}

export type VisColumn = VisNumericalColumn | VisCategoricalColumn;
Expand Down
11 changes: 8 additions & 3 deletions src/vis/scatter/ScatterVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { BrushOptionButtons } from '../sidebar/BrushOptionButtons';
import { fitRegressionLine } from './Regression';
import { ELabelingOptions, ERegressionLineType, IRegressionResult, IScatterConfig } from './interfaces';
import { createScatterTraces, defaultRegressionLineStyle } from './utils';
import { VIS_TRACES_COLOR } from '../general/constants';

const formatPValue = (pValue: number) => {
if (pValue === null) {
Expand Down Expand Up @@ -181,7 +182,7 @@ export function ScatterVis({
showarrow: false,
font: {
size: 16,
color: '#7f7f7f',
color: VIS_TRACES_COLOR,
},
}),
);
Expand Down Expand Up @@ -220,11 +221,11 @@ export function ScatterVis({
b: 50,
},
shapes: [],
grid: { rows: traces.rows, columns: traces.cols, xgap: 0.3, pattern: 'independent' },
grid: { rows: traces.rows, columns: traces.cols, xgap: 0.15, ygap: config.facets ? 0.2 : 0.15, pattern: 'independent' },
dragmode: config.dragMode,
};

setLayout({ ...layout, ...beautifyLayout(traces, innerLayout, layout, false) });
setLayout({ ...layout, ...beautifyLayout(traces, innerLayout, layout, null, false) });
// WARNING: Do not update when layout changes, that would be an infinite loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [traces, config.dragMode, showLegend]);
Expand Down Expand Up @@ -349,6 +350,10 @@ export function ScatterVis({
selectionCallback([...selectedList, clickedId]);
}
}}
onLegendClick={() => false}
onDoubleClick={() => {
selectionCallback([]);
}}
onInitialized={() => {
d3.select(id).selectAll('.legend').selectAll('.traces').style('opacity', 1);
}}
Expand Down
Loading

0 comments on commit d429dc2

Please sign in to comment.