src/app/modules/ontology-exploration/ontology-tree/ontology-tree.component.ts
Represents a expandable tree of an ontology.
OnInit
OnChanges
changeDetection | ChangeDetectionStrategy.OnPush |
selector | ccf-ontology-tree |
styleUrls | ./ontology-tree.component.scss |
templateUrl | ./ontology-tree.component.html |
Properties |
|
Methods |
Inputs |
Outputs |
Accessors |
constructor(cdr: ChangeDetectorRef, ga: GoogleAnalyticsService)
|
||||||||||||
Creates an instance of ontology tree component.
Parameters :
|
getChildren | |
Type : GetChildrenFunc | undefined
|
|
Method for fetching the children of a node. |
header | |
Type : boolean
|
|
menuOptions | |
Type : string[]
|
|
nodes | |
Type : [] | undefined
|
|
The node like objects to display in the tree. |
occurenceData | |
Type : Record<string, number>
|
|
Occurence Data is a record of terms that are in the current filter. |
ontologyFilter | |
Type : string[]
|
|
Input of ontology filter, used for changing the ontology selections from outside this component. |
rootNode | |
Type : string
|
|
The root node IRI of the tree |
showtoggle | |
Type : boolean
|
|
termData | |
Type : Record<string, number>
|
|
Term Data is a record of terms that the app currently has data for. |
tooltips | |
Type : string[]
|
|
nodeChanged | |
Type : EventEmitter
|
|
Emits an event whenever the node's visibility changed |
nodeSelected | |
Type : EventEmitter
|
|
Emits an event whenever a node has been selected. |
selectedBiomarkerOptions | |
Type : EventEmitter
|
|
selectionChange | |
Type : EventEmitter
|
|
Any time a button is clicked, event is emitted. |
expandAndSelect | ||||||||||||||||||||
expandAndSelect(node: OntologyTreeNode, getParent: (n: OntologyTreeNode) => void, additive)
|
||||||||||||||||||||
Expands the tree to show a node and sets the currect selection to that node.
Parameters :
Returns :
void
|
getCountLabel | ||||||||
getCountLabel(node: FlatNode)
|
||||||||
Gets a label for the count
Parameters :
Returns :
string
Label for the count |
getNodeLabel | ||||||||
getNodeLabel(label: string)
|
||||||||
Gets Node label
Parameters :
Returns :
string
label for node |
isInnerNode | ||||||||||||||||
isInnerNode(this: void, _index: number, node: FlatNode)
|
||||||||||||||||
Determines whether a node can be expanded.
Parameters :
Returns :
boolean
True if the node has children. |
isItemSelected | ||||||
isItemSelected(item: string)
|
||||||
Parameters :
Returns :
any
|
isSelected | ||||||||
isSelected(node: FlatNode | undefined)
|
||||||||
Determines whether a node is currently selected. Only a single node can be selected at any time.
Parameters :
Returns :
boolean
True if the node is the currently selected node. |
onScroll | ||||||||
onScroll(event: Event)
|
||||||||
Handles the scroll event to detect when scroll is at the bottom.
Parameters :
Returns :
void
|
select | ||||||||||||||||||||
select(ctrlKey: boolean, node: FlatNode | undefined, emit: boolean, select: boolean)
|
||||||||||||||||||||
Handles selecting / deselecting nodes via updating the selectedNodes variable
Parameters :
Returns :
void
|
selectByIDs | ||||||
selectByIDs(ids: string[])
|
||||||
Parameters :
Returns :
void
|
toggleSelection | ||||||
toggleSelection(value: string[])
|
||||||
Parameters :
Returns :
void
|
anySelectionsMade |
Default value : false
|
Keeping track of the first selection made allows us to ensure the 'body' node is unselected as expected. |
atScrollBottom |
Default value : false
|
Readonly control |
Default value : new FlatTreeControl<FlatNode>(getLevel, isExpandable)
|
Tree controller. |
Readonly dataSource |
Default value : new MatTreeFlatDataSource(this.control, this.flattener)
|
Data source of flat nodes. |
Readonly flattener |
Default value : new MatTreeFlattener(
FlatNode.create,
getLevel,
isExpandable,
invoke.bind(undefined, this, 'getChildren') as GetChildrenFunc,
)
|
Node flattener. |
Readonly indent |
Type : number | string
|
Default value : '1.5rem'
|
Indentation of each level in the tree. |
selectedNodes |
Type : FlatNode[]
|
Default value : []
|
Currently selected nodes, defaulted to the body node for when the page initially loads. |
selectedtoggleOptions |
Type : string[]
|
nodes | ||||||
getnodes()
|
||||||
List of nodes in the ontology tree
Returns :
[] | undefined
|
||||||
setnodes(nodes: OntologyTreeNode[] | undefined)
|
||||||
The node like objects to display in the tree.
Parameters :
Returns :
void
|
getChildren | ||||||
getgetChildren()
|
||||||
setgetChildren(fun: GetChildrenFunc | undefined)
|
||||||
Method for fetching the children of a node.
Parameters :
Returns :
void
|
occurenceData | ||||||
getoccurenceData()
|
||||||
setoccurenceData(value: Record
|
||||||
Occurence Data is a record of terms that are in the current filter.
Parameters :
Returns :
void
|
termData | ||||||
gettermData()
|
||||||
settermData(value: Record
|
||||||
Term Data is a record of terms that the app currently has data for.
Parameters :
Returns :
void
|
import { FlatTreeControl } from '@angular/cdk/tree';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree';
import { OntologyTreeNode } from 'ccf-database';
import { filter, invoke, property } from 'lodash';
import { GoogleAnalyticsService } from 'ngx-google-analytics';
import { FlatNode } from '../../../core/models/flat-node';
export const labelMap = new Map([
['colon', 'large intestine'],
['body', 'Anatomical Structures (AS)'],
['cell', 'Cell Types (CT)'],
]);
/** Type of function for getting child nodes from a parent node. */
type GetChildrenFunc = (o: OntologyTreeNode) => OntologyTreeNode[];
/**
* Getter function for 'level' on a flat node.
*/
const getLevel = property<FlatNode, number>('level');
/**
* Getter function for 'expandable' on a flat node.
*/
const isExpandable = property<FlatNode, boolean>('expandable');
/**
* Represents a expandable tree of an ontology.
*/
@Component({
selector: 'ccf-ontology-tree',
templateUrl: './ontology-tree.component.html',
styleUrls: ['./ontology-tree.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OntologyTreeComponent implements OnInit, OnChanges {
/**
* Input of ontology filter, used for changing the ontology selections
* from outside this component.
*/
@Input() ontologyFilter!: string[];
/**
* The root node IRI of the tree
*/
@Input() rootNode!: string;
@Input() showtoggle!: boolean;
/**
* The node like objects to display in the tree.
*/
// eslint-disable-next-line
@Input()
set nodes(nodes: OntologyTreeNode[] | undefined) {
this._nodes = nodes;
if (this.control) {
this.dataSource.data = this._nodes ?? [];
}
}
/**
* List of nodes in the ontology tree
*/
get nodes(): OntologyTreeNode[] | undefined {
return this._nodes;
}
/**
* Method for fetching the children of a node.
*/
@Input()
set getChildren(fun: GetChildrenFunc | undefined) {
this._getChildren = fun;
this.dataSource.data = this.nodes ?? [];
}
get getChildren(): GetChildrenFunc | undefined {
return this._getChildren;
}
/**
* Occurence Data is a record of terms that are in the current filter.
*/
// eslint-disable-next-line
@Input()
set occurenceData(value: Record<string, number>) {
if (value) {
this._occurenceData = value;
} else {
this._occurenceData = {};
}
}
get occurenceData(): Record<string, number> {
return this._occurenceData;
}
/**
* Storage for the getter / setter
*/
private _occurenceData!: Record<string, number>;
/**
* Term Data is a record of terms that the app currently has data for.
*/
@Input()
set termData(value: Record<string, number>) {
if (value) {
this._termData = value;
} else {
this._termData = {};
}
}
get termData(): Record<string, number> {
return this._termData;
}
@Input() header!: boolean;
@Input() menuOptions!: string[];
@Input() tooltips!: string[];
selectedtoggleOptions!: string[];
/**
* Storage for the getter / setter
*/
private _termData!: Record<string, number>;
atScrollBottom = false;
/**
* Creates an instance of ontology tree component.
*
* @param cdr The change detector.
* @param ga Analytics service
*/
constructor(
private readonly cdr: ChangeDetectorRef,
private readonly ga: GoogleAnalyticsService,
) {}
/**
* Emits an event whenever a node has been selected.
*/
@Output() readonly nodeSelected = new EventEmitter<OntologyTreeNode[]>();
/**
* Emits an event whenever the node's visibility changed
*/
@Output() readonly nodeChanged = new EventEmitter<FlatNode>();
/**
* Any time a button is clicked, event is emitted.
*/
@Output() readonly selectionChange = new EventEmitter<string[]>();
@Output() readonly selectedBiomarkerOptions = new EventEmitter<string[]>();
/**
* Indentation of each level in the tree.
*/
readonly indent: number | string = '1.5rem';
/**
* Tree controller.
*/
readonly control = new FlatTreeControl<FlatNode>(getLevel, isExpandable);
/**
* Node flattener.
*/
readonly flattener = new MatTreeFlattener(
FlatNode.create,
getLevel,
isExpandable,
invoke.bind(undefined, this, 'getChildren') as GetChildrenFunc,
);
/**
* Data source of flat nodes.
*/
readonly dataSource = new MatTreeFlatDataSource(this.control, this.flattener);
/**
* Storage for getter/setter 'nodes'.
*/
private _nodes?: OntologyTreeNode[] = undefined;
/**
* Storage for getter/setter 'getChildren'.
*/
private _getChildren?: GetChildrenFunc;
/**
* Keeping track of the first selection made allows us to ensure the 'body' node
* is unselected as expected.
*/
anySelectionsMade = false;
/**
* Currently selected nodes, defaulted to the body node for when the page initially loads.
*/
selectedNodes: FlatNode[] = [];
/**
* Expand the body node when the component is initialized.
*/
ngOnInit(): void {
if (this.control.dataNodes) {
this.control.expand(this.control.dataNodes[0]);
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['ontologyFilter']) {
const ontologyFilter: string[] = changes['ontologyFilter'].currentValue as string[];
if (ontologyFilter?.length >= 0) {
this.selectByIDs(ontologyFilter);
}
}
if (changes['rootNode']) {
const rootNode = changes['rootNode'].currentValue;
this.selectByIDs([rootNode]);
}
if (changes['nodes']) {
this.selectByIDs([this.rootNode]);
}
}
selectByIDs(ids: string[]): void {
const dataNodes = this.control.dataNodes;
const selectedNodes: FlatNode[] = dataNodes.filter((node) => ids.indexOf(node.original.id) > -1);
if (selectedNodes?.length > 0) {
this.selectedNodes = selectedNodes;
this.ga.event('nodes_selected_by_ids', 'ontology_tree', selectedNodes.map((node) => node.label).join(','));
this.control.collapseAll();
this.selectedNodes.forEach((selectedNode) => {
this.expandAndSelect(
selectedNode.original,
(node) => dataNodes.find((findNode) => findNode.original.id === node.parent)?.original as OntologyTreeNode,
true,
);
});
}
}
/**
* Expands the tree to show a node and sets the currect selection to that node.
*
* @param node The node to expand to and select.
*/
expandAndSelect(
node: OntologyTreeNode,
getParent: (n: OntologyTreeNode) => OntologyTreeNode,
additive = false,
): void {
const { cdr, control } = this;
// Add all parents to a set
const parents = new Set<OntologyTreeNode>();
let current = getParent(node);
while (current) {
parents.add(current);
current = getParent(current);
}
// Find corresponding flat nodes
const parentFlatNodes = filter(control.dataNodes, (flat) => parents.has(flat.original));
const flatNode = control.dataNodes.find((flat) => flat.original === node);
// Expand nodes
if (!additive) {
this.selectedNodes = [];
control.collapseAll();
}
for (const flat of parentFlatNodes) {
control.expand(flat);
}
if ((node.label === 'body' || node.id === 'biomarkers') && control.dataNodes?.length > 0) {
control.expand(control.dataNodes[0]);
}
// Select the node
this.select(additive, flatNode, false, true);
// Detect changes
cdr.detectChanges();
}
/**
* Determines whether a node can be expanded.
*
* @param node The node to test.
* @returns True if the node has children.
*/
isInnerNode(this: void, _index: number, node: FlatNode): boolean {
return node.expandable;
}
/**
* Gets a label for the count
* @param node The flat node instance
* @returns Label for the count
*/
getCountLabel(node: FlatNode): string {
return !node.original.parent ? 'Tissue Blocks: ' : '';
}
/**
* Gets Node label
* @param label node label
* @returns label for node
*/
getNodeLabel(label: string): string {
return labelMap.get(label) ?? label;
}
/**
* Determines whether a node is currently selected.
* Only a single node can be selected at any time.
*
* @param node The node to test.
* @returns True if the node is the currently selected node.
*/
isSelected(node: FlatNode | undefined): boolean {
return (
node?.original.id === this.rootNode ||
this.selectedNodes.filter((selectedNode) => node?.original.label === selectedNode?.original.label).length > 0
);
}
/**
* Handles selecting / deselecting nodes via updating the selectedNodes variable
*
* @param node The node to select.
* @param ctrlKey Whether or not the selection was made with a ctrl + click event.
*/
select(ctrlKey: boolean, node: FlatNode | undefined, emit: boolean, select: boolean): void {
// This is to ensure the 'body' node is unselected regardless of what the first
// selection is
if (!this.anySelectionsMade) {
this.selectedNodes = [];
this.anySelectionsMade = true;
}
if (node === undefined) {
this.selectedNodes = [];
this.ga.event('nodes_unselected', 'ontology_tree');
return;
}
// ctrl + click allows users to select multiple organs
if (ctrlKey) {
if (!select) {
this.selectedNodes.splice(this.selectedNodes.indexOf(node), 1);
} else if (this.selectedNodes.indexOf(node) < 0) {
this.selectedNodes.push(node);
}
} else {
this.selectedNodes = [];
if (select) {
this.selectedNodes.push(node);
}
}
this.ga.event('nodes_selected', 'ontology_tree', this.selectedNodes.map((n) => n.label).join(','));
if (emit) {
this.nodeSelected.emit(this.selectedNodes.map((selectedNode) => selectedNode?.original));
}
}
/**
* Handles the scroll event to detect when scroll is at the bottom.
*
* @param event The scroll event.
*/
onScroll(event: Event): void {
if (!event.target) {
return;
}
const { clientHeight, scrollHeight, scrollTop } = event.target as Element;
const diff = scrollHeight - scrollTop - clientHeight;
this.atScrollBottom = diff < 20;
}
isItemSelected(item: string) {
return this.selectedtoggleOptions.includes(item);
}
toggleSelection(value: string[]) {
this.selectedtoggleOptions = value;
this.selectedBiomarkerOptions.emit([...this.selectedtoggleOptions]);
}
}
<mat-tree
class="ccf-ontology-tree"
[class.header-hidden]="!header"
[dataSource]="dataSource"
[treeControl]="control"
(scroll)="onScroll($event)"
>
<!-- Templates with common structures for inner and leaf nodes -->
<ng-template #selectableRegion let-node="node">
<div
class="text"
[class.filtered-out]="!occurenceData[node.original.id] && !!termData[node.original.id]"
[class.unavailable]="!termData[node.original.id]"
[class.selected]="isSelected(node)"
(click)="select($event.ctrlKey, node, true, !isSelected(node))"
>
{{ getNodeLabel(node.label) }}
</div>
</ng-template>
<!-- Leaf node template -->
<mat-tree-node
*matTreeNodeDef="let node"
class="node leaf-node block"
matTreeNodePadding
[matTreeNodePaddingIndent]="indent"
>
<!-- Disabled button used to add equal amount of space as an inner node's button -->
<div class="non-expandable"></div>
<div class="node-container">
<ng-container *ngTemplateOutlet="selectableRegion; context: { node: node }"></ng-container>
<div class="num-results" [class.suborgan]="node.level > 1">
{{ getCountLabel(node) }}{{ occurenceData[node.original.id] || 0 }}
</div>
</div>
<div class="biomarkers-toggle" *ngIf="showtoggle && node.original.id === 'biomarkers'">
<ccf-button-toggle
[menuOptions]="menuOptions"
[selectedItems]="selectedtoggleOptions"
(selectionChange)="toggleSelection($event)"
></ccf-button-toggle>
</div>
</mat-tree-node>
<!-- Inner node template -->
<mat-tree-node
*matTreeNodeDef="let node; when: isInnerNode"
class="node inner-node block"
matTreeNodePadding
[matTreeNodePaddingIndent]="indent"
>
<div class="node-container">
<button class="toggle" mat-icon-button matTreeNodeToggle attr.aria-label="Toggle {{ node.label }}">
<mat-icon class="icon font-icon">
{{ control.isExpanded(node) ? 'expand_less' : 'expand_more' }}
</mat-icon>
</button>
<ng-container *ngTemplateOutlet="selectableRegion; context: { node: node }"></ng-container>
<div class="num-results" [class.suborgan]="node.level > 1">
{{ getCountLabel(node) }}{{ occurenceData[node.original.id] || 0 }}
</div>
</div>
<div class="biomarkers-toggle" *ngIf="showtoggle && node.original.id === 'biomarkers'">
<ccf-button-toggle
[tooltips]="tooltips"
[enableTooltip]="true"
[menuOptions]="menuOptions"
[selectedItems]="selectedtoggleOptions ?? menuOptions"
(selectionChange)="toggleSelection($event)"
></ccf-button-toggle>
</div>
</mat-tree-node>
</mat-tree>
./ontology-tree.component.scss
.ccf-ontology-tree {
background: none;
scrollbar-width: thin;
overflow: hidden;
&.header-hidden {
max-height: 40vh;
}
.node:first-of-type {
.toggle {
z-index: -1;
opacity: 0;
}
.text {
margin-left: 0.5rem;
}
}
.node-container {
display: flex;
}
.biomarkers-toggle::ng-deep {
margin: 0.5rem;
padding-left: 1rem;
}
.node {
min-height: 0;
font-size: 1rem;
margin-bottom: 0.25rem;
.slider {
width: 100%;
transition-duration: 0.25s;
transition-timing-function: ease-in-out;
transition-property: width;
position: relative;
z-index: 1;
&.hidden {
width: 0;
ccf-opacity-slider {
display: none;
}
}
::ng-deep ccf-opacity-slider {
height: 1.5rem;
margin-left: 1rem;
.slider-box {
width: 100%;
.slider-and-label {
width: 100%;
}
}
.mat-slider {
height: 1.5rem;
width: 15rem;
.mat-slider-wrapper {
top: 12px;
}
}
.opacity-value {
width: 3rem;
}
}
}
.num-results {
margin-right: 0.25rem;
}
&.inner-node {
button {
&.hidden {
display: none;
}
}
}
.opacity {
position: relative;
min-width: 1.5rem;
}
.toggle {
width: 1.5rem;
height: 1.5rem;
line-height: normal;
padding: 0;
}
.autocomplete-open .toggle {
border-bottom: none;
}
.non-expandable {
margin-left: 1.5rem;
}
.text {
cursor: pointer;
margin-left: 1rem;
opacity: 1;
transition-duration: 0.25s;
transition-timing-function: ease-in-out;
transition-property: opacity, width;
&.hidden {
opacity: 0;
width: 0%;
}
&.unavailable {
pointer-events: none;
}
}
.num-results {
margin-left: auto;
}
}
.block {
display: block;
}
}
.scroll-gradient {
position: absolute;
height: 3rem;
width: 90%;
bottom: 0;
pointer-events: none;
&.hidden {
display: none;
}
}