UNPKG

owl-bt

Version:

owl-bt is editor for Behavior trees. It has been inspired by Unreal engine behavior trees in a way, that it supports special node items like decorators and services. This makes trees smaller and much more readable.

488 lines (431 loc) 17.2 kB
(function () { 'use strict'; const separationX = 50; const separationY = 70; const baseWidth = 150; //should be somewhere between min and max width const baseHeight = 40; const linkStartHeight = 10; const marginX = 50; const marginY = 50; //init tree size must be enough for a node so the node's width won't be smaller due to wrapping const minTreeWidth = 500; const minTreeHeight = 500; const minZoom = 0.1; const maxZoom = 2; const dragDistancePreventDefault = 10; const focusMoveDuration = 300; const scrollToNodeTopOffset = 100; let hasPanned; function selectItem(TreeSelection, item) { if (item) { TreeSelection.select(item.viewNode ? item.viewNode.nodeItem : item.nodeItem, item.nodeItem); } else { TreeSelection.select(); } } function showContextMenu(CommandContextMenu, d3, TreeSelection, scope, viewNodeItem) { scope.$apply(function () { selectItem(TreeSelection, viewNodeItem); CommandContextMenu.show(scope, d3.event); }); } function bindMouseEvents(CommandContextMenu, d3, TreeSelection, scope, nodeItemElm, viewNodeItem) { nodeItemElm .data([viewNodeItem]) .on('click', item => { d3.event.stopPropagation(); if (!hasPanned) { scope.$apply(function () { selectItem(TreeSelection, item); }); } }) .on('contextmenu', item => { d3.event.preventDefault(); d3.event.stopPropagation(); if (!hasPanned) { showContextMenu(CommandContextMenu, d3, TreeSelection, scope, item); } }); } function bindTreeElmMouseEvents(CommandContextMenu, d3, TreeSelection, scope, treeElm) { treeElm .on('click', () => { if (!hasPanned) { scope.$apply(function () { selectItem(TreeSelection, null); }); } }) .on('contextmenu', () => { d3.event.preventDefault(); d3.event.stopPropagation(); if (!hasPanned) { CommandContextMenu.hide(); scope.$apply(function () { selectItem(TreeSelection, null); }); } }); } function getNodeItemNamePrefix(nodeItem) { if (nodeItem.inverseCheckCondition) { return '[not] '; } return ''; } function addNodeItemElm(CommandContextMenu, d3, TreeNodeItem, TreeSelection, scope, viewNodeItem, nodeElm, cssClass, index) { let desc = viewNodeItem.desc; let nodeItemElm = nodeElm.append('div'); let propertiesAreValid = TreeNodeItem.propertiesAreValid(viewNodeItem.nodeItem); nodeItemElm .attr('class', 'item ' + cssClass + (desc.isInvalid || !propertiesAreValid ? ' invalid' : '')) .attr('data-index', index) .append('span') .attr('class', `icon fa fa-${desc.icon}`); let itemContentElm = nodeItemElm.append('div') .attr('class', 'content'); const defaultName = getNodeItemNamePrefix(viewNodeItem.nodeItem) + desc.name; const nameElm = itemContentElm .append('div') .attr('class', 'name'); if (viewNodeItem.nodeItem.label && viewNodeItem.nodeItem.label.length) { nameElm.append('span') .attr('class', 'name-sub') .text(`(${defaultName})`); nameElm.append('span') .text(viewNodeItem.nodeItem.label); } else { nameElm.text(defaultName); } if (viewNodeItem.nodeItem.comment) { itemContentElm.classed('content-with-comment', true); itemContentElm .append('span') .attr('class', 'comment-icon fa fa-info-circle'); itemContentElm.append('div') .attr('class', 'comment-content') .text(viewNodeItem.nodeItem.comment); } if (desc.description) { itemContentElm .append('div') .attr('class', 'desc') .text(TreeNodeItem.getDescription(viewNodeItem.nodeItem)); } if (TreeSelection.isSelected(viewNodeItem.nodeItem)) { nodeItemElm.classed('selected', true); } if (desc.color) { if (typeof (index) === 'undefined') { // not a item inside node itemContentElm.style('background', desc.color); } itemContentElm.style('border-left-color', desc.color); } bindMouseEvents(CommandContextMenu, d3, TreeSelection, scope, nodeItemElm, viewNodeItem); } function getNodeKey(viewNode) { return `${viewNode.id}_${viewNode.version}`; } function refreshNodes(CommandContextMenu, TreeNodeItem, d3, TreeSelection, scope, rootViewNode, nodeElmsData) { //add new nodes nodeElmsData .enter() .append('div') .attr('class', viewNode => (viewNode.desc.isComposite ? 'composite' : 'action') + ' node') .attr('data-node-id', viewNode => viewNode.id) .attr('data-node-key', viewNode => getNodeKey(viewNode)) .each(function (viewNode) { let nodeElm = d3.select(this); addNodeItemElm(CommandContextMenu, d3, TreeNodeItem, TreeSelection, scope, viewNode, nodeElm, 'node-desc'); if (viewNode.decorators) { viewNode.decorators.forEach(function (viewNodeItem, index) { addNodeItemElm(CommandContextMenu, d3, TreeNodeItem, TreeSelection, scope, viewNodeItem, nodeElm, 'decorator', index); }); } if (viewNode.services) { viewNode.services.forEach(function (viewNodeItem, index) { addNodeItemElm(CommandContextMenu, d3, TreeNodeItem, TreeSelection, scope, viewNodeItem, nodeElm, 'service', index); }); } if (viewNode.viewNodeItem === scope.selectedNode) { nodeElm.classed('selected', true); } }); //remove removed nodes nodeElmsData .exit() .remove(); //update mouse event bindings } function getMaxHeightPerLevel(viewNodes) { let maxHeightPerLevel = []; for (let i = 0; i < viewNodes.length; i++) { let viewNode = viewNodes[i]; let isFirstOnLevel = maxHeightPerLevel.length <= viewNode.depth; if (isFirstOnLevel) { maxHeightPerLevel.push(viewNode.height); } else { let maxHightOnLevel = maxHeightPerLevel[viewNode.depth]; if (maxHightOnLevel < viewNode.height) { maxHeightPerLevel[viewNode.depth] = viewNode.height; } } } return maxHeightPerLevel; } function getYPosPerLevel(viewNodes) { let maxHeightPerLevel = getMaxHeightPerLevel(viewNodes); let yPosPerLevel = []; let currentY = 0; for (let i = 0; i < maxHeightPerLevel.length; i++) { yPosPerLevel.push(currentY); currentY += maxHeightPerLevel[i] + separationY; } return yPosPerLevel; } function getTreeBounds(viewNodes, yPosPerLevel) { let res = {}; for (let i = 0; i < viewNodes.length; i++) { let viewNode = viewNodes[i]; let left = viewNode.x - viewNode.width * 0.5; let top = yPosPerLevel[viewNode.depth]; let right = left + viewNode.width; let bottom = top + viewNode.height; if (_.isUndefined(res.minX) || left < res.minX) { res.minX = left; } if (_.isUndefined(res.maxX) || right > res.maxX) { res.maxX = right; } if (_.isUndefined(res.minY) || top < res.minY) { res.minY = top; } if (_.isUndefined(res.maxY) || bottom > res.maxY) { res.maxY = bottom; } } //apply margin res.minX = (res.minX || 0) - marginX; res.minY = (res.minY || 0) - marginY; res.maxX = (res.maxX || 0) + marginX; res.maxY = (res.maxY || 0) + marginY; return res; } function refreshLinks(d3, svgElm, viewNodes, nodeElms, treeLayout) { let links = treeLayout.links(viewNodes); let diagonal = d3.svg.diagonal() .projection(viewNode => [viewNode.x, viewNode.y]) .source(link => ({ 'x': link.source.x + link.source.width * 0.5, 'y': (link.source.y + link.source.height + linkStartHeight) })) .target(link => ({ 'x': (link.target.x + link.target.width * 0.5), 'y': link.target.y })); let linkElmsData = svgElm.selectAll('.link') .data(links); linkElmsData .enter().append('path') .attr('class', 'link'); linkElmsData .exit() .remove(); linkElmsData .attr('d', diagonal); //link starts let linkStartElmsData = svgElm.selectAll('.link-start') .data(viewNodes.filter(viewNode => viewNode.children && viewNode.children.length > 0)); linkStartElmsData .enter() .append('line') .attr('class', 'link-start'); linkStartElmsData .exit() .remove(); linkStartElmsData .attr('x1', viewNode => viewNode.x + viewNode.width * 0.5) .attr('y1', viewNode => viewNode.y + viewNode.height) .attr('x2', viewNode => viewNode.x + viewNode.width * 0.5) .attr('y2', viewNode => viewNode.y + viewNode.height + linkStartHeight); } function refreshTree(CommandContextMenu, TreeViewModelProvider, TreeNodeItem, d3, TreeSelection, scope, rootNode, treeElm, treeContentElm, svgElm) { //TODO: reuse tree view model between refreshes in case of perf problems let rootViewNode = TreeViewModelProvider.create(rootNode); //because D3 (and also this method) enhances nodes with its fields and parent field creates circular reference let viewNodes = TreeViewModelProvider.getAllNodes(rootViewNode); let nodeElms = treeContentElm.selectAll('.node'); //http://stackoverflow.com/questions/30890212/data-join-with-custom-key-does-not-work-as-expected let nodeElmsData = nodeElms.data(viewNodes, function (viewNode) { return Array.isArray(this) ? getNodeKey(viewNode) : d3.select(this).attr('data-node-key'); }); //init tree size must be enough for a node so the node's width won't be smaller due to text wrapping //but at a same time it must be big enough to contain current nodes if (parseInt(treeContentElm.style('width')) < minTreeWidth) { treeContentElm.style('width', `${minTreeWidth}px`); } if (parseInt(treeContentElm.style('height')) < minTreeHeight) { treeContentElm.style('height', `${minTreeHeight}px`); } //create nodes before tree layout to get real width/height in separation function refreshNodes(CommandContextMenu, TreeNodeItem, d3, TreeSelection, scope, rootViewNode, nodeElmsData); //update node width and height from content nodeElmsData .each(function (viewNode) { //width and height from content viewNode.width = this.offsetWidth; viewNode.height = this.offsetHeight; }); //Compute layout let treeLayout = d3.layout.tree() .nodeSize([baseWidth, baseHeight]) .separation((viewNode1, viewNode2) => ((viewNode1.width + viewNode2.width) + separationX) / baseWidth * 0.5); treeLayout.nodes(rootViewNode); let yPosPerLevel = getYPosPerLevel(viewNodes); let treeBounds = getTreeBounds(viewNodes, yPosPerLevel); //set node positions let offsetX = -treeBounds.minX; let offsetY = -treeBounds.minY; nodeElmsData .each(function (viewNode) { viewNode.x += offsetX - viewNode.width * 0.5; viewNode.y = yPosPerLevel[viewNode.depth] + offsetY; }) .attr('style', viewNode => `left:${viewNode.x}px;top:${viewNode.y}px;`); refreshLinks(d3, svgElm, viewNodes, nodeElms, treeLayout); // update tree dimensions treeContentElm .style('width', `${treeBounds.maxX - treeBounds.minX}px`) .style('height', `${treeBounds.maxY - treeBounds.minY}px`); svgElm .attr('width', treeBounds.maxX - treeBounds.minX) .attr('height', treeBounds.maxY - treeBounds.minY); } function createZoomHandler(d3, treeContentElm) { let zoom; let handleZoom = function () { let translate = zoom.translate(); treeContentElm.style('transform', `translate(${translate[0]}px,${translate[1]}px) scale(${zoom.scale()})`); }; let zoomStart = {}; zoom = d3.behavior.zoom() .scaleExtent([minZoom, maxZoom]) .on('zoom', handleZoom) .on('zoomstart', () => { if (d3.event.sourceEvent) { zoomStart = { x: d3.event.sourceEvent.clientX, y: d3.event.sourceEvent.clientY }; } }) .on('zoomend', () => { if (d3.event.sourceEvent) { let distance = Math.sqrt(Math.pow(d3.event.sourceEvent.clientX - zoomStart.x, 2) + Math.pow(d3.event.sourceEvent.clientY - zoomStart.y, 2)); if (distance >= dragDistancePreventDefault) { hasPanned = true; } } }); // .on('dblclick.zoom', null); return { zoom: zoom, handleZoom: handleZoom }; } function getNodeSelector(node) { return `.node[data-node-id="${node.$meta.id}"]`; } function onSelectionChanged(TreeNode, TreeSelection, treeContentElm) { //deselect treeContentElm.selectAll('.item.selected').classed('selected', false); //select if (TreeSelection.hasSelected()) { let nodeElm = treeContentElm.select(getNodeSelector(TreeSelection.selNode())); if (TreeSelection.selItemType() === 'node') { nodeElm.selectAll('.item.node-desc').classed('selected', true); //selectAll instead of select - https://github.com/mbostock/d3/issues/1443 } else { let itemClass = 'decorator'; let subItemIndex = TreeNode.indexOfDecorator(TreeSelection.selNode(), TreeSelection.selItem()); if (subItemIndex === -1) { itemClass = 'service'; subItemIndex = TreeNode.indexOfService(TreeSelection.selNode(), TreeSelection.selItem()); } if (subItemIndex > -1) { nodeElm.selectAll(`.item.${itemClass}[data-index="${subItemIndex}"]`).classed('selected', true); //selectAll instead of select - https://github.com/mbostock/d3/issues/1443 } } } } function scrollToNode(d3, zoomHandler, treeElmRaw, node, duration) { if (node) { let nodeElmRaw = treeElmRaw.querySelector(getNodeSelector(node)); if (nodeElmRaw) { let scale = zoomHandler.zoom.scale(); let destTranslate = [scale * (-nodeElmRaw.offsetLeft - nodeElmRaw.offsetWidth * 0.5) + treeElmRaw.offsetWidth * 0.5, scale * (-nodeElmRaw.offsetTop) + scrollToNodeTopOffset ]; if (duration) { d3.transition().duration(duration).tween('scrollToNode', function () { let iTranslate = d3.interpolate(zoomHandler.zoom.translate(), destTranslate); return function (t) { zoomHandler.zoom.translate(iTranslate(t)); zoomHandler.handleZoom(); }; }); } else { zoomHandler.zoom.translate(destTranslate); zoomHandler.handleZoom(); } } } } function treeView(TreeStore, TreeSelection, TreeViewModelProvider, TreeNodeItem, TreeNode, d3, CommandContextMenu, TreeFocus) { return { template: require('./treeView.html'), restrict: 'EA', replace: true, scope: {}, link: function (scope, element) { // note: don't use replaceWith because of jquery problems with svg // note: don't use foreignObject because of webkit scale bugs // note: d3 node building instead of angular template is used to get the real width and height before exiting the link function and also for perf reasons let treeElmRaw = element[0]; let treeElm = d3.select(element[0]); let treeContentElm = d3.select(element[0].querySelector('.tree-content')); let svgElm = treeContentElm.append('svg'); let zoomHandler = createZoomHandler(d3, treeContentElm); TreeFocus.subscribe(scope, function (node) { scrollToNode(d3, zoomHandler, treeElmRaw, node, focusMoveDuration); }); bindTreeElmMouseEvents(CommandContextMenu, d3, TreeSelection, scope, treeElm); TreeStore.ensureLoad() .then(() => { let rootNode = TreeStore.rootNode; let firstRender = true; scope.$watch(() => TreeStore.version, function () { refreshTree(CommandContextMenu, TreeViewModelProvider, TreeNodeItem, d3, TreeSelection, scope, rootNode, treeElm, treeContentElm, svgElm); if (firstRender) { treeElm .on('mousedown', () => { //must be called before call(zoomHandler.zoom) because of stopImmediatePropagation hasPanned = false; if (d3.event.button !== 2) { d3.event.stopImmediatePropagation(); } }) .call(zoomHandler.zoom) .on('dblclick.zoom', null); scrollToNode(d3, zoomHandler, treeElmRaw, rootNode); firstRender = false; } }); scope.$watch(() => TreeSelection.selItem(), function () { onSelectionChanged(TreeNode, TreeSelection, treeContentElm); }); }); } }; } angular.module('editorApp') .directive('treeView', treeView); })();