UNPKG

pragma-views2

Version:

749 lines (660 loc) 25.7 kB
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);