Skip to content

Commit

Permalink
Merge pull request #813 from VEuPathDB/tidytree-component
Browse files Browse the repository at this point in the history
TreeTable React component
  • Loading branch information
bobular authored Mar 4, 2024
2 parents 3f1d17d + f68302e commit d344d72
Show file tree
Hide file tree
Showing 14 changed files with 1,204 additions and 60 deletions.
7 changes: 7 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,10 @@ plugins:
- path: .yarn/plugins/@husky-install/plugin-install-husky.cjs

yarnPath: .yarn/releases/yarn-3.3.1.cjs

#
# zip/checksum behaviour is platform-specific (e.g. Windows vs Linux) for github: repos
# e.g. packages/libs/components import of tidytree
# see https://github.com/yarnpkg/berry/issues/5795
#
checksumBehavior: ignore
20 changes: 20 additions & 0 deletions packages/libs/components/.storybook/main.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { addD3Shimming } = require('../webpack-shimming');

module.exports = {
typescript: {
check: true,
Expand All @@ -11,6 +13,17 @@ module.exports = {
'@storybook/addon-viewport/register',
'./redmine-addon/register.js',
],
babel: async (options) => {
return {
...options,
presets: [...options.presets, '@emotion/babel-preset-css-prop'],
// See https://stackoverflow.com/questions/70406632/typescript-parameter-properties-not-working-with-storybook-rollup-in-developm
plugins: options.plugins.filter(
(x) =>
!(typeof x === 'string' && x.includes('plugin-transform-classes'))
),
};
},
webpackFinal: async (config, { configType }) => {
// `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
// You can change the configuration based on that.
Expand All @@ -30,6 +43,13 @@ module.exports = {
},
});

addD3Shimming(config.module.rules);

config.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
});

// Return the altered config
return config;
},
Expand Down
12 changes: 12 additions & 0 deletions packages/libs/components/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import '@storybook/addon-console';
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';

// storybook v6 seems to have default margin so remove it - https://github.com/storybookjs/storybook/issues/12109
export const parameters = {
controls: { expanded: true },
layout: 'fullscreen',
};

// wrap all stories in react-query (for useQuery hook)
const queryClient = new QueryClient();
export const decorators = [
(Story) => (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
),
];
8 changes: 7 additions & 1 deletion packages/libs/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"url": "https://github.com/veupathdb/web-components.git"
},
"dependencies": {
"@emotion/css": "^11.11.2",
"@typescript-eslint/eslint-plugin": "^5.46.0",
"@typescript-eslint/parser": "^5.46.0",
"@veupathdb/coreui": "workspace:^",
Expand All @@ -24,6 +25,7 @@
"bootstrap": "^4.5.2",
"color-math": "^1.1.3",
"d3": "^7.1.1",
"d3v5": "npm:d3@5",
"date-arithmetic": "^4.1.0",
"debounce-promise": "^3.1.2",
"dom-to-image": "^2.6.0",
Expand All @@ -42,7 +44,8 @@
"react-plotly.js": "^2.4.0",
"react-spring": "^9.7.1",
"react-transition-group": "^4.4.1",
"shape2geohash": "^1.2.5"
"shape2geohash": "^1.2.5",
"tidytree": "github:d-callan/TidyTree"
},
"files": [
"lib",
Expand Down Expand Up @@ -107,11 +110,14 @@
"babel-loader": "^8.3.0",
"eslint": "^8.29.0",
"eslint-plugin-react-hooks": "^4.6.0",
"exports-loader": "^1.1.1",
"imports-loader": "^1.1.1",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"npm-run-all": "^4.1.5",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-query": "^3.39.3",
"stats-lite": "^2.2.0",
"storybook": "^6.5.14",
"typescript": "4.3.4"
Expand Down
1 change: 1 addition & 0 deletions packages/libs/components/public/data/newick-example.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { TidyTree as TidyTreeJS } from 'tidytree';

export interface HorizontalDendrogramProps {
/// The first set of props are expected to be fairly constant ///
/// and when changed, a whole new TidyTreeJS instance will be created ///
/**
* tree data in Newick format
*/
data: string | undefined;
/**
* TO DO: add width prop and nail down most of the options to
* horizontal dendrograms
*/
options: {
ruler?: boolean;
/**
* supposedly [ top, right, bottom, left ] but in practice not at all
for now just default to all zero margins (left-most edges
*/
margin?: [number, number, number, number];
};

/// The remaining props are handled with a redraw: ///
/**
* how many leaf nodes are in the data string
* (maybe we can calculate this from the Newick string in future?)
*/
leafCount: number;
/**
* width of tree in pixels
*/
width: number;
/**
* number of pixels height taken per leaf
*/
rowHeight: number;
/**
* which leaf nodes to highlight
*/
highlightedNodeIds?: string[];
/**
* highlight whole subtrees ('monophyletic') or just leaves ('none')
*/
highlightMode?: 'monophyletic' | 'none';
}

/**
* This is hardwired to produce a horizontal, equally spaced, square cornered dendrogram.
* A more general purpose wrapping of TidyTreeJS will have to come later. It's not trivial
* due to horizontal/vertical orientation affecting heights and widths. Given that users expect
* to scroll up/down in a large tree rather than left/right, and because we will be aligning
* it with a table that also works in up/down space, this seems reasonable.
*/
export function HorizontalDendrogram({
data,
leafCount,
rowHeight,
width,
options: { ruler = false, margin = [0, 0, 0, 0] },
highlightedNodeIds,
highlightMode,
}: HorizontalDendrogramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const tidyTreeRef = useRef<TidyTreeJS>();

useEffect(() => {
if (containerRef.current == null || data == null) {
// If props.data is nullish and containerRef.current exists, clear its content
if (containerRef.current) {
containerRef.current.innerHTML = ''; // Clear the container for blank rendering
}
return;
}
const instance = new TidyTreeJS(data, {
parent: containerRef.current,
layout: 'horizontal',
type: 'dendrogram',
mode: 'square',
equidistantLeaves: true,
ruler,
margin,
animation: 0, // it's naff and it reveals edge lengths/weights momentarily
});
tidyTreeRef.current = instance;
return function cleanup() {
instance.destroy();
};
}, [data, ruler, margin]);

// redraw when the container size changes
// useLayoutEffect ensures that the redraw is not called for brand new TidyTreeJS objects
// look out for potential performance issues (the effect is run synchronously)
useLayoutEffect(() => {
if (tidyTreeRef.current) {
tidyTreeRef.current.redraw();
}
}, [leafCount, width, rowHeight, tidyTreeRef]);

// now handle changes to props that act via tidytree methods
// which also usually trigger a redraw

// highlightedNodeIds
useEffect(() => {
if (tidyTreeRef.current && highlightedNodeIds) {
tidyTreeRef.current.setColorOptions({
nodeColorMode: 'predicate',
branchColorMode: highlightMode ?? 'none',
leavesOnly: true,
predicate: (node) => highlightedNodeIds.includes(node.__data__.data.id),
});
// no redraw needed, setColorOptions does it
}
}, [highlightedNodeIds, highlightMode, tidyTreeRef]);

const containerHeight = leafCount * rowHeight;
return (
<div
style={{
width: width + 'px',
height: containerHeight + 'px',
}}
ref={containerRef}
/>
);
}
91 changes: 91 additions & 0 deletions packages/libs/components/src/components/tidytree/TreeTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { useMemo } from 'react'; // import React seems to be needed
import {
HorizontalDendrogram,
HorizontalDendrogramProps,
} from '../../components/tidytree/HorizontalDendrogram';
import Mesa from '@veupathdb/coreui/lib/components/Mesa';
import { MesaStateProps } from '../../../../coreui/lib/components/Mesa/types';
import { css as classNameStyle, cx } from '@emotion/css';
import { css as globalStyle, Global } from '@emotion/react';

export interface TreeTableProps<RowType> {
/**
* number of pixels vertical space for each row of the table and tree
* (for the table this is a minimum height, so make sure table content doesn't wrap)
*/
rowHeight: number;
/**
* data and options for the tree
*/
treeProps: Omit<
HorizontalDendrogramProps,
'leafCount' | 'options' | 'rowHeight'
>;
/**
* data and options for the table
*/
tableProps: MesaStateProps<RowType>;
}

/**
* main props are
* data: string; // Newick format tree
* rows: RowType[]; // array of row objects
* columns: MesaColumn[]; // column configurations (see Storybook story)
* width: number; // width of the tree
* rowHeight: number; // height of rows in table and leaves in tree
*
* The tree should have the same number of leaf nodes as rows.length!
* This is not currently validated by the component.
*
* Probably TO DO:
* - allow additional Mesa props and options to be passed
*/
export default function TreeTable<RowType>(props: TreeTableProps<RowType>) {
const { rowHeight } = props;
const { rows } = props.tableProps;

const rowStyleClassName = useMemo(
() =>
cx(
classNameStyle({
height: rowHeight + 'px',
background: 'yellow',
})
),
[rowHeight]
);

// tableState is just the tableProps with an extra CSS class
// to make sure the height is consistent with the tree
const tableState: MesaStateProps<RowType> = {
...props.tableProps,
options: {
...props.tableProps.options,
deriveRowClassName: (_) => rowStyleClassName,
},
};

return (
<div
style={{ display: 'flex', alignItems: 'flex-end', flexDirection: 'row' }}
>
<HorizontalDendrogram
{...props.treeProps}
rowHeight={rowHeight}
leafCount={rows.length}
options={{ margin: [0, 10, 0, 10] }}
/>
<>
<Global
styles={globalStyle`
.DataTable {
margin-bottom: 0px !important;
}
`}
/>
<Mesa state={tableState} />
</>
</div>
);
}
27 changes: 27 additions & 0 deletions packages/libs/components/src/components/tidytree/tidytree.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// this is just the subset of props we are dealing with
interface Node {
__data__: {
data: {
id: string; // the display label provided in the Newick file
length: number;
};
};
}

interface ColorOptions {
predicate: (node: Node) => boolean;
leavesOnly: boolean;
nodeColorMode: 'predicate' | 'none';
branchColorMode: 'monophyletic' | 'none';
highlightColor?: string;
defaultNodeColor?: string;
}

declare module 'tidytree' {
export declare class TidyTree {
constructor(data: any, options: any);
destroy(): void;
redraw(): void;
setColorOptions(newColorOptions: ColorOptions): void;
}
}
Loading

0 comments on commit d344d72

Please sign in to comment.