vitessce
Version:
Vitessce app and React component library
652 lines (614 loc) • 21.3 kB
JavaScript
/* eslint-disable no-underscore-dangle */
import uuidv4 from 'uuid/v4';
import isNil from 'lodash/isNil';
import isEqual from 'lodash/isEqual';
import range from 'lodash/range';
import { featureCollection as turfFeatureCollection, point as turfPoint } from '@turf/helpers';
import centroid from '@turf/centroid';
import concaveman from 'concaveman';
import {
HIERARCHICAL_SCHEMAS,
} from './constants';
import { getDefaultColor, PALETTE } from '../utils';
import { pathToKey } from './utils';
/**
* Alias for the uuidv4 function to make code more readable.
* @returns {string} UUID.
*/
function generateKey() {
return uuidv4();
}
/**
* Get the set associated with a particular node.
* Recursive.
* @param {object} currNode A node object.
* @returns {array} The array representing the set associated with the node.
*/
export function nodeToSet(currNode) {
if (!currNode) {
return [];
}
if (!currNode.children) {
return (currNode.set || []);
}
return currNode.children.flatMap(c => nodeToSet(c));
}
/**
* Get the height of a node (the number of levels to reach a leaf).
* @param {object} currNode A node object.
* @param {number} level The level that the height will be computed relative to. By default, 0.
* @returns {number} The height. If the node has a .children property,
* then the minimum value returned is 1.
*/
export function nodeToHeight(currNode, level = 0) {
if (!currNode.children) {
return level;
}
const newLevel = level + 1;
const childrenHeights = currNode.children.map(c => nodeToHeight(c, newLevel));
return Math.max(...childrenHeights, newLevel);
}
/**
* Get the size associated with a particular node.
* Recursive.
* @param {object} currNode A node object.
* @returns {number} The length of all the node's children
*/
export function getNodeLength(currNode) {
if (!currNode) {
return 0;
}
if (!currNode.children) {
return (currNode.set?.length || 0);
}
return currNode.children.reduce((acc, curr) => acc + getNodeLength(curr), 0);
}
/**
* Find a node with a matching name path, relative to a particular node.
* @param {object} node A node object.
* @param {string[]} path The name path for the node of interest.
* @param {number} currLevelIndex The index of the current hierarchy level.
* @returns {object|null} A matching node object, or null if none is found.
*/
function nodeFindNodeByNamePath(node, path, currLevelIndex) {
const currNodeName = path[currLevelIndex];
if (node.name === currNodeName) {
if (currLevelIndex === path.length - 1) {
return node;
}
if (node.children) {
const foundNodes = node.children
.map(child => nodeFindNodeByNamePath(child, path, currLevelIndex + 1))
.filter(Boolean);
if (foundNodes.length === 1) {
return foundNodes[0];
}
}
}
return null;
}
/**
* Find a node with a matching name path, relative to the whole tree.
* @param {object} currTree A tree object.
* @param {string[]} targetNamePath The name path for the node of interest.
* @returns {object|null} A matching node object, or null if none is found.
*/
export function treeFindNodeByNamePath(currTree, targetNamePath) {
const foundNodes = currTree.tree
.map(levelZeroNode => nodeFindNodeByNamePath(levelZeroNode, targetNamePath, 0))
.filter(Boolean);
if (foundNodes.length === 1) {
return foundNodes[0];
}
return null;
}
/**
* Transform a node object using a transform function.
* @param {object} node A node object.
* @param {function} predicate Returns true if a node matches a condition of interest.
* @param {function} transform Takes the node matching the predicate as input, returns
* a transformed version of the node.
* @param {array} transformedPaths This array parameter is mutated. The path of
* each transformed node is appended to this array.
* @param {string[]} The current path of the node being updated, used internally
* during recursion.
* @returns {object} The updated node.
*/
export function nodeTransform(node, predicate, transform, transformedPaths, currPath) {
let newPath;
if (!currPath) {
newPath = [node.name];
} else {
newPath = [...currPath];
}
if (predicate(node, newPath)) {
transformedPaths.push(newPath);
return transform(node, newPath);
}
if (node.children) {
return {
...node,
children: node.children.map(
child => nodeTransform(
child, predicate, transform, transformedPaths, newPath.concat([child.name]),
),
),
};
}
return node;
}
/**
* Transform many node objects using a transform function.
* @param {object} node A node object.
* @param {function} predicate Returns true if a node matches a condition of interest.
* @param {function} transform Takes the node matching the predicate as input, returns
* a transformed version of the node.
* @param {array} transformedPaths This array parameter is mutated. The path of
* each transformed node is appended to this array.
* @param {string[]} The current path of the node being updated, used internally
* during recursion.
* @returns {object} The updated node.
*/
export function nodeTransformAll(node, predicate, transform, transformedPaths, currPath) {
let newPath;
if (!currPath) {
newPath = [node.name];
} else {
newPath = [...currPath];
}
let newNode = node;
if (predicate(node, newPath)) {
transformedPaths.push(newPath);
newNode = transform(node, newPath);
}
if (node.children) {
return {
...newNode,
children: newNode.children.map(
child => nodeTransformAll(
child, predicate, transform, transformedPaths, newPath.concat([child.name]),
),
),
};
}
return newNode;
}
/**
* Append a child to a parent node.
* @param {object} currNode A node object.
* @param {object} newChild The child node object.
* @returns {object} The updated node.
*/
export function nodeAppendChild(currNode, newChild) {
return {
...currNode,
children: [...currNode.children, newChild],
};
}
/**
* Prepend a child to a parent node.
* @param {object} currNode A node object.
* @param {object} newChild The child node object.
* @returns {object} The updated node.
*/
export function nodePrependChild(currNode, newChild) {
return {
...currNode,
children: [newChild, ...currNode.children],
};
}
/**
* Insert a child to a parent node.
* @param {object} currNode A node object.
* @param {*} newChild The child node object.
* @param {*} insertIndex The index at which to insert the child.
* @returns {object} The updated node.
*/
export function nodeInsertChild(currNode, newChild, insertIndex) {
const newChildren = Array.from(currNode.children);
newChildren.splice(insertIndex, 0, newChild);
return {
...currNode,
children: newChildren,
};
}
/**
* Get an array representing the union of the sets of checked nodes.
* @param {object} currTree A tree object.
* @returns {array} An array representing the union of the sets of checked nodes.
*/
export function treeToUnion(currTree, checkedPaths) {
const nodes = checkedPaths.map(path => treeFindNodeByNamePath(currTree, path));
const nodeSets = nodes.map(node => nodeToSet(node).map(([cellId]) => cellId));
return nodeSets
.reduce((a, h) => a.concat(h.filter(hEl => !a.includes(hEl))), nodeSets[0] || []);
}
/**
* Get an array representing the intersection of the sets of checked nodes.
* @param {object} currTree A tree object.
* @returns {array} An array representing the intersection of the sets of checked nodes.
*/
export function treeToIntersection(currTree, checkedPaths) {
const nodes = checkedPaths.map(path => treeFindNodeByNamePath(currTree, path));
const nodeSets = nodes.map(node => nodeToSet(node).map(([cellId]) => cellId));
return nodeSets
.reduce((a, h) => h.filter(hEl => a.includes(hEl)), nodeSets[0] || []);
}
/**
* Get an array representing the complement of the union of the sets of checked nodes.
* @param {object} currTree
* @returns {array} An array representing the complement of the
* union of the sets of checked nodes.
*/
export function treeToComplement(currTree, checkedPaths, items) {
const primaryUnion = treeToUnion(currTree, checkedPaths);
return items.filter(el => !primaryUnion.includes(el));
}
/**
* Get an flattened array of descendants at a particular relative
* level of interest.
* @param {object} node A node object.
* @param {number} level The relative level of interest.
* 0 for this node's children, 1 for grandchildren, etc.
* @param {boolean} stopEarly Should a node be returned early if no children exist?
* @returns {object[]} An array of descendants at the specified level,
* where the level is relative to the node.
*/
export function nodeToLevelDescendantNamePaths(node, level, prevPath, stopEarly = false) {
if (!node.children) {
if (!stopEarly) {
return null;
}
return [[...prevPath, node.name]];
}
if (level === 0) {
return [[...prevPath, node.name]];
}
return node.children
.flatMap(c => nodeToLevelDescendantNamePaths(c, level - 1, [...prevPath, node.name], stopEarly))
.filter(Boolean);
}
/**
* Export the tree by clearing tree state and all node states.
* @param {object} currTree A tree object.
* @returns {object} Tree object with tree and node state removed.
*/
export function treeExport(currTree, datatype) {
return {
version: HIERARCHICAL_SCHEMAS[datatype].latestVersion,
datatype,
tree: currTree.tree,
};
}
/**
* Export the tree by clearing tree state and all node states,
* and filter so that only the level zero node of interest is included.
* @param {object} currTree A tree object.
* @param {string} nodePath The path of the node of interest.
* @param {string} dataType Datatype (i.e cell sets)
* @param {Array} cellSetColors Array of objects of cell set colors and paths
* @param {string} theme "light" or "dark" for the vitessce theme
* @returns {object} { treeToExport, nodeName }
* Tree with one level zero node, and with state removed.
*/
export function treeExportLevelZeroNode(currTree, nodePath, datatype, cellSetColors, theme) {
const node = treeFindNodeByNamePath(currTree, nodePath);
const nodeWithColors = nodeTransformAll(node, () => true, (n, nPath) => {
const nodeColor = cellSetColors?.find(c => isEqual(c.path, nPath))?.color
?? getDefaultColor(theme);
return {
...n,
color: nodeColor.slice(0, 3),
};
}, []);
const treeWithOneLevelZeroNode = {
...currTree,
tree: [nodeWithColors],
};
return {
treeToExport: treeExport(treeWithOneLevelZeroNode, datatype),
nodeName: node.name,
};
}
/**
* Prepare the set of a node of interest for export.
* @param {object} currTree A tree object.
* @param {string} nodeKey The key of the node of interest.
* @returns {object} { setToExport, nodeName } The set as an array.
*/
export function treeExportSet(currTree, nodePath) {
const node = treeFindNodeByNamePath(currTree, nodePath);
return { setToExport: nodeToSet(node), nodeName: node.name };
}
/**
* Get an empty tree, with a default tree state.
* @param {string} datatype The type of sets that this tree contains.
* @returns {object} Empty tree.
*/
export function treeInitialize(datatype) {
return {
version: HIERARCHICAL_SCHEMAS[datatype].latestVersion,
datatype,
tree: [],
};
}
/**
* For convenience, get an object with information required
* to render a node as a component.
* @param {object} node A node to be rendered.
* @returns {object} An object containing properties required
* by the TreeNode render functions.
*/
export function nodeToRenderProps(node, path, cellSetColor) {
const level = path.length - 1;
return {
title: node.name,
nodeKey: pathToKey(path),
path,
size: getNodeLength(node),
color: cellSetColor?.find(d => isEqual(d.path, path))?.color,
level,
isLeaf: (!node.children || node.children.length === 0) && Boolean(node.set),
height: nodeToHeight(node),
};
}
/**
* Using a color and a probability, mix the color with an "uncertainty" color,
* for example, gray.
* Reference: https://github.com/bgrins/TinyColor/blob/80f7225029c428c0de0757f7d98ac15f497bee57/tinycolor.js#L701
* @param {number[]} originalColor The color assignment for the class.
* @param {number} p The mixing amount, or level certainty in the originalColor classification,
* between 0 and 1.
* @param {number[]} mixingColor The color with which to mix. By default, [128, 128, 128] gray.
* @returns {number[]} Returns the color after mixing.
*/
function colorMixWithUncertainty(originalColor, p, mixingColor = [128, 128, 128]) {
return [
((originalColor[0] - mixingColor[0]) * p) + mixingColor[0],
((originalColor[1] - mixingColor[1]) * p) + mixingColor[1],
((originalColor[2] - mixingColor[2]) * p) + mixingColor[2],
];
}
/**
* Given a tree with state, get the cellIds and cellColors,
* based on the nodes currently marked as "visible".
* @param {object} currTree A tree object.
* @param {array} selectedNamePaths Array of arrays of strings,
* representing set "paths".
* @param {object[]} cellSetColor Array of objects with the
* properties `path` and `color`.
* @param {string} theme "light" or "dark" for the vitessce theme
* @returns {array} Tuple of [cellIds, cellColors]
* where cellIds is an array of strings,
* and cellColors is an object mapping cellIds to color [r,g,b] arrays.
*/
export function treeToCellColorsBySetNames(currTree, selectedNamePaths, cellSetColor, theme) {
let cellColorsArray = [];
selectedNamePaths.forEach((setNamePath) => {
const node = treeFindNodeByNamePath(currTree, setNamePath);
if (node) {
const nodeSet = nodeToSet(node);
const nodeColor = (
cellSetColor?.find(d => isEqual(d.path, setNamePath))?.color
|| getDefaultColor(theme)
);
cellColorsArray = [
...cellColorsArray,
...nodeSet.map(([cellId, prob]) => [
cellId,
(isNil(prob) ? nodeColor : colorMixWithUncertainty(nodeColor, prob)),
]),
];
}
});
return new Map(cellColorsArray);
}
/**
* Given a tree with state, get an array of
* objects with cellIds and cellColors,
* based on the nodes currently marked as "visible".
* @param {object} currTree A tree object.
* @param {array} selectedNamePaths Array of arrays of strings,
* representing set "paths".
* @param {object[]} setColor Array of objects with the
* properties `path` and `color`
* @param {string} theme "light" or "dark" for the vitessce theme.
* @returns {object[]} Array of objects with properties
* `obsId`, `name`, and `color`.
*/
export function treeToObjectsBySetNames(currTree, selectedNamePaths, setColor, theme) {
let cellsArray = [];
for (let i = 0; i < selectedNamePaths.length; i += 1) {
const setNamePath = selectedNamePaths[i];
const node = treeFindNodeByNamePath(currTree, setNamePath);
if (node) {
const nodeSet = nodeToSet(node);
const nodeColor = (
setColor?.find(d => isEqual(d.path, setNamePath))?.color
|| getDefaultColor(theme)
);
cellsArray = cellsArray.concat(nodeSet.map(([cellId]) => ({
obsId: cellId,
name: node.name,
color: nodeColor,
})));
}
}
return cellsArray;
}
export function treeToCellPolygonsBySetNames(
currTree, cells, mapping, selectedNamePaths, cellSetColor, theme,
) {
const cellSetPolygons = [];
selectedNamePaths.forEach((setNamePath) => {
const node = treeFindNodeByNamePath(currTree, setNamePath);
if (node) {
const nodeSet = nodeToSet(node);
const nodeColor = (
cellSetColor?.find(d => isEqual(d.path, setNamePath))?.color
|| getDefaultColor(theme)
);
const cellPositions = nodeSet
.map(([cellId]) => ([
cells[cellId]?.mappings[mapping][0],
-cells[cellId]?.mappings[mapping][1],
]))
.filter(cell => cell.every(i => typeof i === 'number'));
if (cellPositions.length > 2) {
const points = turfFeatureCollection(
cellPositions.map(turfPoint),
);
const concavity = Infinity;
const hullCoords = concaveman(cellPositions, concavity);
if (hullCoords) {
const centroidCoords = centroid(points).geometry.coordinates;
cellSetPolygons.push({
path: setNamePath,
name: setNamePath[setNamePath.length - 1],
hull: hullCoords,
color: nodeColor,
centroid: centroidCoords,
});
}
}
}
});
return cellSetPolygons;
}
/**
* Given a tree with state, get the sizes of the
* sets currently marked as "visible".
* @param {object} currTree A tree object.
* @param {array} selectedNamePaths Array of arrays of strings,
* representing set "paths".
* @param {object[]} setColor Array of objects with the
* properties `path` and `color`.
* @param {string} theme "light" or "dark" for the vitessce theme
* @returns {object[]} Array of objects
* with the properties `name`, `size`, `key`,
* and `color`.
*/
export function treeToSetSizesBySetNames(currTree, selectedNamePaths, setColor, theme) {
const sizes = [];
selectedNamePaths.forEach((setNamePath) => {
const node = treeFindNodeByNamePath(currTree, setNamePath);
if (node) {
const nodeSet = nodeToSet(node);
const nodeColor = setColor?.find(d => isEqual(d.path, setNamePath))?.color
|| getDefaultColor(theme);
sizes.push({
key: generateKey(),
name: node.name,
size: nodeSet.length,
color: nodeColor,
});
}
});
return sizes;
}
/**
* Find and remove a node from the descendants of the current node.
* @param {object} node A node to search on.
* @param {array} prevPath Path of the current node to be searched.
* @param {array} filterPath The path sought.
* @returns {object} A new node without a node at filterPath.
*/
export function filterNode(node, prevPath, filterPath) {
if (isEqual([...prevPath, node.name], filterPath)) {
return null;
}
if (!node.children) {
return node;
}
return {
...node,
children: node.children.map(
c => filterNode(c, [...prevPath, node.name], filterPath),
).filter(Boolean),
};
}
export function treeToExpectedCheckedLevel(currTree, checkedPaths) {
let result = null;
if (currTree) {
currTree.tree.forEach((lzn) => {
const levelZeroPath = [lzn.name];
const height = nodeToHeight(lzn);
range(height).forEach((i) => {
const levelIndex = i + 1;
const levelNodePaths = nodeToLevelDescendantNamePaths(lzn, levelIndex, [], true);
if (isEqual(levelNodePaths, checkedPaths)) {
result = { levelZeroPath, levelIndex };
}
});
});
}
return result;
}
export function treesConflict(cellSets, testCellSets) {
const paths = [];
const testPaths = [];
let hasConflict = false;
function getPaths(node, prevPath) {
paths.push([...prevPath, node.name]);
if (node.children) {
node.children.forEach(c => getPaths(c, [...prevPath, node.name]));
}
}
cellSets.tree.forEach(lzn => getPaths(lzn, []));
function getTestPaths(node, prevPath) {
testPaths.push([...prevPath, node.name]);
if (node.children) {
node.children.forEach(c => getPaths(c, [...prevPath, node.name]));
}
}
testCellSets.tree.forEach(lzn => getTestPaths(lzn, []));
testPaths.forEach((testPath) => {
if (paths.find(p => isEqual(p, testPath))) {
hasConflict = true;
}
});
return hasConflict;
}
export function initializeCellSetColor(cellSets, cellSetColor) {
const nextCellSetColor = [...(cellSetColor || [])];
const nodeCountPerTreePerLevel = cellSets.tree.map(tree => Array
.from({
length: nodeToHeight(tree) + 1, // Need to add one because its an array.
}).fill(0));
function processNode(node, prevPath, hierarchyLevel, treeIndex) {
const index = nodeCountPerTreePerLevel[treeIndex][hierarchyLevel];
const nodePath = [...prevPath, node.name];
const nodeColor = nextCellSetColor.find(d => isEqual(d.path, nodePath));
if (!nodeColor) {
// If there is a color for the node specified via the cell set tree,
// then use it. Otherwise, use a color from the default color palette.
const nodeColorArray = (node.color ? node.color : PALETTE[index % PALETTE.length]);
nextCellSetColor.push({
path: nodePath,
color: nodeColorArray,
});
}
nodeCountPerTreePerLevel[treeIndex][hierarchyLevel] += 1;
if (node.children) {
node.children.forEach(c => processNode(c, nodePath, hierarchyLevel + 1, treeIndex));
}
}
cellSets.tree.forEach((lzn, treeIndex) => processNode(lzn, [], 0, treeIndex));
return nextCellSetColor;
}
export function getCellSetPolygons(params) {
const {
cells,
mapping,
cellSets,
cellSetSelection,
cellSetColor,
theme,
} = params;
if (cellSetSelection && cellSetSelection.length > 0 && cellSets && cells) {
return treeToCellPolygonsBySetNames(
cellSets, cells, mapping, cellSetSelection, cellSetColor, theme,
);
}
return [];
}