File

src/app/modules/ontology-exploration/ontology-tree/ontology-tree.component.ts

Description

Represents a expandable tree of an ontology.

Implements

OnInit OnChanges

Metadata

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor(cdr: ChangeDetectorRef, ga: GoogleAnalyticsService)

Creates an instance of ontology tree component.

Parameters :
Name Type Optional Description
cdr ChangeDetectorRef No

The change detector.

ga GoogleAnalyticsService No

Analytics service

Inputs

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[]

Outputs

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.

Methods

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 :
Name Type Optional Default value Description
node OntologyTreeNode No

The node to expand to and select.

getParent function No
additive No false
Returns : void
getCountLabel
getCountLabel(node: FlatNode)

Gets a label for the count

Parameters :
Name Type Optional Description
node FlatNode No

The flat node instance

Returns : string

Label for the count

getNodeLabel
getNodeLabel(label: string)

Gets Node label

Parameters :
Name Type Optional Description
label string No

node label

Returns : string

label for node

isInnerNode
isInnerNode(this: void, _index: number, node: FlatNode)

Determines whether a node can be expanded.

Parameters :
Name Type Optional Description
this void No
_index number No
node FlatNode No

The node to test.

Returns : boolean

True if the node has children.

isItemSelected
isItemSelected(item: string)
Parameters :
Name Type Optional
item string No
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 :
Name Type Optional Description
node FlatNode | undefined No

The node to test.

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 :
Name Type Optional Description
event Event No

The scroll event.

Returns : void
select
select(ctrlKey: boolean, node: FlatNode | undefined, emit: boolean, select: boolean)

Handles selecting / deselecting nodes via updating the selectedNodes variable

Parameters :
Name Type Optional Description
ctrlKey boolean No

Whether or not the selection was made with a ctrl + click event.

node FlatNode | undefined No

The node to select.

emit boolean No
select boolean No
Returns : void
selectByIDs
selectByIDs(ids: string[])
Parameters :
Name Type Optional
ids string[] No
Returns : void
toggleSelection
toggleSelection(value: string[])
Parameters :
Name Type Optional
value string[] No
Returns : void

Properties

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[]

Accessors

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 :
Name Type Optional
nodes OntologyTreeNode[] | undefined No
Returns : void
getChildren
getgetChildren()
setgetChildren(fun: GetChildrenFunc | undefined)

Method for fetching the children of a node.

Parameters :
Name Type Optional
fun GetChildrenFunc | undefined No
Returns : void
occurenceData
getoccurenceData()
setoccurenceData(value: Record)

Occurence Data is a record of terms that are in the current filter.

Parameters :
Name Type Optional
value Record<string | number> No
Returns : void
termData
gettermData()
settermData(value: Record)

Term Data is a record of terms that the app currently has data for.

Parameters :
Name Type Optional
value Record<string | number> No
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;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""