pragma-views2
Version:
749 lines (660 loc) • 25.7 kB
JavaScript
import {HierarchicalBase} from '../lib/hierarchical-base.js';
import {BaseShortcuts} from '../lib/base-shortcuts.js';
class PragmaBranch extends HierarchicalBase {
/**
* Creates new hierarchy node and appends it to the parent(selected node).
* @param selectedNode: D3 node
* @param data: data to create new node
* @private
*/
_addNode(selectedNode, data) {
const newNode = d3.hierarchy(data);
newNode.depth = selectedNode.depth + 1;
newNode.height = selectedNode.height - 1;
newNode.parent = selectedNode;
newNode.id = ++this._i;
newNode.data.hasChildren = newNode.data.model[this._expandRef] === true;
if(selectedNode.children == null){
selectedNode.children = [];
selectedNode.data.children = [];
}
selectedNode.children.push(newNode);
selectedNode.data.children.push(newNode.data);
selectedNode.data.isExpanded = true;
};
/**
* Centers specified node
* @param source: node data
* @private
*/
_centerNode(source) {
const x = -source.y0 + this._centerPoint.x;
const y = -source.x0 + this._centerPoint.y;
this._svg.transition()
.duration(this._duration)
.attr('transform', `translate(${x}, ${y})`)
.attr('will-change', 'transform');
}
/**
* click event handler
* @param event: event object
*/
async _click(event) {
const node = event.target.__data__;
if (node == null) {
return;
}
event.preventDefault();
switch (event.target.parentElement.getAttribute('role')) {
case 'treeitem':
this._handleSelection(node, event.target.parentElement);
break;
case 'button':
await this._handleExpandCollapse(node);
break;
default:
break;
}
}
/**
* Fetch children for specified parent item and initiate update ui
* @param node: node data (parent)
* @param element: node element (parent)
* @returns {Promise<void>}
* @private
*/
async _fetchChildrenFor(node, element) {
this._loadBusyIndicator(element);
await this.datasource.load(null, node.data);
if (node.data.items != null) {
node.data.hasChildren = node.data.items.length > 0;
for (const item of node.data.items) {
this._addNode(node, item);
}
this._updateTree(node);
}
this._removeBusyIndicator(element);
}
/**
* Compute curve based on parent and child node axises
* @param s: parent node
* @param d: child node
* @returns {string}: path definition
* @private
*/
_getCurve(s, d) {
return `M ${s.y} ${s.x} C ${(s.y + d.y) / 2} ${s.x}, ${(s.y + d.y) / 2} ${d.x}, ${d.y} ${d.x}`;
}
/**
* Returns node element based on a specified id
* @param id: Node id
* @returns {*}: element
* @private
*/
_getNodeElement(id) {
return d3.selectAll('g.node').filter(d => d.id === id).node();
}
/**
* Construct root object and fetch child items
* @returns {Promise<{model: {name: *|null}, isExpanded: boolean, hasChildren: boolean}>}: root data
* @private
*/
async _getRootData() {
const rootData = {
model: { name: this._rootName },
isExpanded: true,
hasChildren: true
};
await this.datasource.load(null, rootData);
if (rootData.items != null) {
for (const item of rootData.items) {
item.hasChildren = item.model[this._expandRef] === true;
}
}
return rootData;
}
/**
*
* @param text
* @returns {number}: Text width
* @private
*/
_getTextWidth(text) {
const context = this._canvas.getContext('2d');
context.font = '16px appFont, sans-serif';
return context.measureText(text).width;
}
/**
* Returns svg path based on isExpanded state
* @param isExpanded
* @returns {string}: path
* @private
*/
_getTreeButtonPath(isExpanded) {
return isExpanded === true
? 'M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z'
: 'M13 7h-2v4H7v2h4v4h2v-4h4v-2h-4V7zm-1-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z';
}
/**
* Toggle existing children or fetch from datasource
* @param node: selected node object
* @returns {Promise<void>}
* @private
*/
async _handleExpandCollapse(node) {
const nodeElement = this._getNodeElement(node.id);
(node.children == null && node._children == null) ? await this._fetchChildrenFor(node, nodeElement) : this._toggleChildren(node);
this._updateButton(node, nodeElement);
this._setFocus(nodeElement);
this._centerNode(node);
}
/**
* Controls node selection state, focus and ui
* @param node: selected node data
* @param target: target element
* @private
*/
_handleSelection(node, target) {
const isSelected = this._isSelected(node);
if (isSelected === true) {
if (this._isMultiSelect()) {
const toBeRemoved = node.descendants()
.filter(node => this._selectedId.includes(node.data.model[this._idField]))
.map(node => node.data.model[this._idField]);
for (const idField of toBeRemoved) {
this._selectedId.splice(this._selectedId.indexOf(idField), 1);
}
}
else {
this._selectedId = -1;
}
}
else {
this._isMultiSelect() ? this._selectedId.push(node.data.model[this._idField]) : this._selectedId = node.data.model[this._idField];
}
this._updateCheckboxes();
this._highlightLinks(node, !isSelected);
this._setFocus(target);
}
/**
* Highlight links from selected child and ancestors
* @param node - Target node data
* @param isSelected - selection state
* @private
*/
_highlightLinks(node, isSelected) {
const highlightedLinks = this._svg.selectAll('path.link.highlighted')
.filter(d => d.depth === node.depth && d !== node);
const ancestorIds = node.ancestors().map(node => node.id);
const links = this._svg.selectAll('path.link');
links.filter(d => d !== node && this._isMultiSelect() === false)
.classed('highlighted', false);
links.filter(d => (isSelected === true || (isSelected === false && highlightedLinks.size() === 0))
? ancestorIds.includes(d.id) : d.id === node.id)
.classed('highlighted', isSelected);
}
/**
* Construct and append svg object to pragma-branch element, draw nodes and links from initial data
* @param treeData: Initial tree data
* @private
*/
_initialiseSvg(treeData) {
this._centerPoint = {x: this.offsetWidth / 2, y: this.offsetHeight / 2};
this._zoomBehavior = d3.zoom()
.scaleExtent([0.1, 8])
.on('zoom', this._zoomHandler);
const initialTransform = d3.zoomIdentity.translate(this._centerPoint.x, this._centerPoint.y);
d3.zoomIdentity.x = this._centerPoint.x;
d3.zoomIdentity.y = this._centerPoint.y;
this._svg = d3.select('pragma-branch')
.append('svg')
.attr('role', 'group')
.call(this._zoomBehavior)
.on('keyup', this._keyupHandler)
.on('focus', () => {})
.on('dblclick.zoom', null)
.on('click', null)
.append('g')
.attr('transform', `translate(${this._centerPoint.x},${this._centerPoint.y})`)
.attr('will-change','transform')
.attr('role', 'tree')
.attr('aria-orientation', 'horizontal')
.attr('aria-multiselectable', this._isMultiSelect());
this._i = 0;
this._duration = 300;
// Declare tree layout, assign tree and node size
this._tree = d3.tree()
.size([this._centerPoint.y * 2, this._centerPoint.x * 2])
.nodeSize([60,40]);
this._root = d3.hierarchy(treeData, d => d.items);
this._root.x0 = this._centerPoint.x;
this._root.y0 = this._centerPoint.y;
this._updateTree(this._root);
const rootElement = this._getNodeElement(1);
this._setFocus(rootElement);
this._svg.call(this._zoomBehavior.transform, initialTransform);
this.children[0].focus();
}
/**
* Returns boolean defining whether selection behaviour is multi or single
* @returns {boolean}
* @private
*/
_isMultiSelect() {
return this._selection === 'multiple' || this._selection == null;
}
/**
* Determine if node is selected by evaluating selected ids array
* @param node: Node object
* @returns {boolean}: is selected
* @private
*/
_isSelected(node) {
return this._isMultiSelect()
? this._selectedId.includes(node.data.model[this._idField])
: this._selectedId === node.data.model[this._idField];
}
/**
* Key up event handler
* @private
*/
async _keyup() {
const node = this._focusItem.__data__;
let toBeFocused;
let performSelect = false;
if (d3.event.ctrlKey && d3.event.altKey) {
switch (d3.event.keyCode) {
case this._baseShortcuts.keyCodes.upArrow:
return this._performPan('up');
case this._baseShortcuts.keyCodes.downArrow:
return this._performPan('down');
case this._baseShortcuts.keyCodes.rightArrow:
return this._performPan('right');
case this._baseShortcuts.keyCodes.leftArrow:
return this._performPan('left');
default:
return;
}
}
switch (d3.event.keyCode) {
case this._baseShortcuts.keyCodes.upArrow:
toBeFocused = this._focusItem.previousSibling;
break;
case this._baseShortcuts.keyCodes.downArrow:
toBeFocused = this._focusItem.nextSibling;
break;
case this._baseShortcuts.keyCodes.rightArrow:
if (node.data.isExpanded) {
toBeFocused = this._getNodeElement(node.children[0].id)
}
else {
performSelect = true;
}
break;
case this._baseShortcuts.keyCodes.leftArrow:
if (node.data.isExpanded) {
performSelect = true;
}
else if (node.parent != null) {
toBeFocused = this._getNodeElement(node.parent.id)
}
break;
case this._baseShortcuts.keyCodes.space:
this._handleSelection(node, this._focusItem);
break;
case this._baseShortcuts.keyCodes.home:
if (node.parent != null) {
toBeFocused = this._getNodeElement(node.parent.children[0].id);
}
break;
case this._baseShortcuts.keyCodes.end:
if (node.parent != null) {
toBeFocused = this._getNodeElement(node.parent.children[node.parent.children.length - 1].id);
}
break;
case 107:
case 187:
this._performZoom(this._zoomFactor);
break;
case 109:
case 189:
this._performZoom(1 / this._zoomFactor);
break;
default:
return;
}
if (toBeFocused != null) {
this._setFocus(toBeFocused);
this._centerNode(toBeFocused.__data__);
}
else if (performSelect && node.data.hasChildren === true) {
await this._handleExpandCollapse(node);
}
}
/**
* Appends busy indicator to specified element at instructed coordinates.
* @param element: element busy indicator will be appended.
* @private
*/
_loadBusyIndicator(element) {
const treeButton = d3.select(element)
.select('.tree-button');
d3.select(element)
.append('g')
.attr('class', 'busy-indicator')
.attr('transform', treeButton.select('path').attr('transform').replace('-12','-10'))
.attr('will-change','transform')
.append('path')
.attr('d', 'M 20 10 C 20 15.52 15.52 20 10 20 C 4.48 20 0 15.52 0 10 C 0 4.48 4.48 0 10 0 L 10 2 C 5.59 2 2 5.59 2 10 C 2 14.41 5.59 18 10 18 C 14.41 18 18 14.41 18 10 L 20 10 Z')
.attr('class', 'loading');
treeButton.attr('display', 'none');
}
/**
* Performs pan action in specified direction
* @param direction
* @private
*/
_performPan(direction) {
const panDistance = 50;
let x = 0;
let y = 0;
switch (direction) {
case 'down':
y -= panDistance;
break;
case 'up':
y += panDistance;
break;
case 'right':
x += panDistance;
break;
case 'left':
x -= panDistance;
break;
default:
return;
}
this._svg.transition().call(this._zoomBehavior.translateBy, x, y);
}
/**
* Performs zoom action according to specified scale factor
* @param scaleFactor
* @private
*/
_performZoom(scaleFactor) {
this._svg.transition().call(this._zoomBehavior.scaleBy, scaleFactor);
}
/**
* Removes busy indicator from specified element
* @param element: element busy indicator will be removed from.
* @private
*/
_removeBusyIndicator(element) {
d3.select(element)
.select('g.busy-indicator')
.remove();
d3.select(element)
.select('.tree-button')
.attr('display', null);
}
/**
* Set focus of element
* @param element: Element to focus
* @private
*/
_setFocus(element) {
if (element.getAttribute('role') !== 'treeitem') {
return;
}
if (this._focusItem != null) {
this._focusItem.classList.remove('focus');
}
this._focusItem = element;
this._focusItem.classList.add('focus');
}
/**
* Toggle children on node
* @param node: node object
* @private
*/
_toggleChildren(node) {
if (node.children != null) {
node._children = node.children;
node.children = null;
node.data.isExpanded = false;
}
else {
node.children = node._children;
node._children = null;
node.data.isExpanded = true;
}
this._updateTree(node);
}
/**
* Update button path and isExpanded state
* @param node: node data
* @param element: node element
* @private
*/
_updateButton(node, element) {
element.setAttribute('aria-expanded', node.data.isExpanded === true);
const treeButton = element.querySelector('g.tree-button > path');
if (treeButton != null) {
treeButton.setAttribute('aria-label', node.data.isExpanded === true ? 'Expand' : 'Collapse');
treeButton.setAttribute('d', this._getTreeButtonPath(node.data.isExpanded));
}
}
/**
* Append or remove checkboxes based on selected items
* @private
*/
_updateCheckboxes() {
const nodes = this._svg.selectAll('g.node');
// Append
nodes.filter((d, i, elements) => this._isSelected(d) === true && elements[i].querySelector('g.checkbox') == null)
.classed('selected', true)
.append('g')
.attr('role', 'presentation')
.attr('class', 'checkbox')
.attr('aria-checked', true)
.append('path')
.attr('id', d => d.id)
.attr('d', 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z');
// Remove
nodes.filter(d => this._isSelected(d) === false)
.classed('selected', false)
.selectAll('g.checkbox')
.remove();
}
/**
* Update links between parent and child nodes
* @param links: Collection of link tree data
* @param source: Selected node data
* @param nodes: Collection of node tree data
* @private
*/
_updateLinks(links, source, nodes) {
const link = this._svg.selectAll('path.link')
.data(links, d => d.id);
// Enter any new links at the parent's previous position.
const linkEnter = link.enter().insert('path', 'g')
.attr('class', 'link')
.attr('role', 'presentation')
.classed('highlighted', d => this._isSelected(d))
.attr('d', () => {
const previousPoint = {x: source.x0, y: source.y0};
return this._getCurve(previousPoint, previousPoint);
});
// Update link
const linkUpdate = linkEnter.merge(link);
// Transition back to the parent element position
linkUpdate.transition()
.duration(this._duration)
.attr('d', d => this._getCurve(d, d.parent));
linkUpdate.classed('highlighted', d => this._isSelected(d));
// Remove any exiting links
const linkExit = link.exit().transition()
.duration(this._duration)
.attr('d', () => {
const currentPoint = {x: source.x, y: source.y};
return this._getCurve(currentPoint, currentPoint);
});
linkExit.remove();
// Store the old positions for transition.
for (const d of nodes) {
d.x0 = d.x;
d.y0 = d.y;
}
}
/**
* Add and remove nodes from svg
* @param nodes: Collection of node tree data
* @param source: Selected node data
* @private
*/
_updateNodes(nodes, source) {
const node = this._svg.selectAll('g.node')
.data(nodes, d => d.id || (d.id = ++this._i));
// Enter any new modes at the parent's previous position.
const nodeEnter = node.enter().append('g')
.attr('id', d => d.id)
.attr('class', 'node')
.attr('transform', () => `translate(${source.y0},${source.x0})`)
.attr('will-change','transform')
.attr('role', 'treeitem')
.attr('aria-label', d => d.data.model[this._labelField])
.attr('aria-expanded', d => d.data.isExpanded === true)
.attr('aria-level', d => d.depth + 1);
// Add rectangle for the nodes
nodeEnter.append('rect')
.attr('role', 'presentation')
.attr('rx', 16)
.attr('ry', 16)
.attr('height', 32)
.attr('width', d => {
const text = d.data.model[this._labelField];
return this._labelWidth[text] == null
? this._labelWidth[text] = this._getTextWidth(text) + 20 + (d.data.hasChildren === true ? 30 : 0)
: this._labelWidth[text];
});
// Add labels for the nodes
nodeEnter.append('text')
.text(d => d.data.model[this._labelField]);
nodeEnter.filter(d => d.data.hasChildren === true)
.append('g')
.attr('role', 'button')
.attr('class', 'tree-button')
.attr('aria-label', d => d.data.isExpanded === true ? 'Expand' : 'Collapse')
.append('path')
.attr('d', d => this._getTreeButtonPath(d.data.isExpanded))
.attr('will-change', 'transform');
// Update node
const nodeUpdate = nodeEnter.merge(node);
// Transition to the proper position for the node
nodeUpdate.transition()
.duration(this._duration)
.attr('transform', d => `translate(${d.y},${d.x})`)
.attr('will-change','transform');
nodeUpdate.select('g.tree-button > path')
.attr('transform', (d, i, paths) => `translate(${paths[i].parentElement.parentElement.querySelector('rect').getAttribute('width') - 30}, -12)`);
// Exit node
const nodeExit = node.exit();
// Remove any exiting nodes
nodeExit.transition()
.duration(this._duration)
.attr('transform', () => `translate(${source.y},${source.x})`)
.attr('will-change','transform')
.remove();
// On exit reduce the node rect size to 0
nodeExit.select('rect')
.attr('width', 0)
.attr('height', 0);
// On exit reduce the opacity of text labels and paths
nodeExit.select('g.checkbox > path').style('fill-opacity', 0);
nodeExit.select('text').style('fill-opacity', 0);
nodeExit.select('g.tree-button > path').style('fill-opacity', 0);
}
/**
* Compute tree layout and update nodes and links
* @param source: Selected node
* @private
*/
_updateTree(source) {
// Assigns the x and y position for the nodes
const treeData = this._tree(this._root);
// Compute the new tree layout
const nodes = treeData.descendants();
const links = treeData.descendants().slice(1);
// Apply child-depth
for (const d of nodes) {
d.y = d.depth * this._depth;
}
this._updateNodes(nodes, source);
this._updateCheckboxes();
this._updateLinks(links, source, nodes);
}
/**
* Add transforms for zoom event
* @private
*/
_zoom() {
this._svg
.attr('transform', d3.event.transform)
.attr('will-change','transform');
}
async connectedCallback() {
super.connectedCallback();
this._canvas = document.createElement('canvas');
this._baseShortcuts = new BaseShortcuts();
this._clickHandler = this._click.bind(this);
this._zoomHandler = this._zoom.bind(this);
this._keyupHandler = this._keyup.bind(this);
this.addEventListener('click', this._clickHandler);
this._idField = this.getAttribute('id-field');
this._rootName = this.getAttribute('root-name') || 'root';
this._labelField = this.getAttribute('label-field') || 'name';
this._expandRef = this.getAttribute('expand-ref') || 'expandable';
this._selection = this.getAttribute('selection');
this._selectedId = this._isMultiSelect() ? [] : -1;
this._zoomFactor = this.getAttribute('zoom-factor');
this._depth = this.getAttribute('child-depth');
this._labelWidth = {};
const rootData = await this._getRootData();
this._initialiseSvg(rootData);
}
disconnectedCallback() {
//reset zoom identity
d3.zoomIdentity.x = 0;
d3.zoomIdentity.y = 0;
//remove event listeners
this.removeEventListener('click', this._clickHandler);
d3.select('svg')
.on('.zoom', null)
.on('keyup', null);
//clear handlers
this._clickHandler = null;
this._zoomBehavior = null;
this._zoomHandler = null;
this._keyupHandler = null;
//clear global variables
this._canvas = null;
this._labelWidth = null;
this._focusItem = null;
this._depth = null;
this._zoomFactor = null;
this._selectedId = null;
this._selection = null;
this._labelField = null;
this._expandRef = null;
this._rootName = null;
this._idField = null;
this._root = null;
this._centerPoint = null;
this._tree = null;
this._duration = null;
this._i = null;
this._svg = null;
super.disconnectedCallback();
}
}
customElements.define('pragma-branch', PragmaBranch);