UNPKG

treehouze

Version:

Graph utilities for handling and housing markdown wikis -- and (semantic) trees!

1,401 lines (1,332 loc) 45.3 kB
import { merge, cloneDeep } from 'lodash'; import 'aframe'; import * as THREE$1 from 'three'; import { SelectionBox } from 'three/addons/interactive/SelectionBox.js'; import ForceGraph from 'force-graph'; import ForceGraph3D from '3d-force-graph'; import ForceGraphAR from '3d-force-graph-ar'; import ForceGraphVR from '3d-force-graph-vr'; import elementResizeDetectorMaker from 'element-resize-detector'; import { Pane } from 'tweakpane'; function styleInject(css, ref) { if (ref === void 0) ref = {}; var insertAt = ref.insertAt; if (!css || typeof document === 'undefined') { return; } var head = document.head || document.getElementsByTagName('head')[0]; var style = document.createElement('style'); style.type = 'text/css'; if (insertAt === 'top') { if (head.firstChild) { head.insertBefore(style, head.firstChild); } else { head.appendChild(style); } } else { head.appendChild(style); } if (style.styleSheet) { style.styleSheet.cssText = css; } else { style.appendChild(document.createTextNode(css)); } } var css_248z = ".select-box {\n position: absolute;\n z-index: 300000;\n border-style: dotted;\n border-color: #3e74cc;\n background-color: rgba(255, 255, 255, 0.5);\n pointer-events: none;\n /* border: 1px solid #55aaff;\n background-color: rgba(75, 160, 255, 0.3);\n position: fixed; */\n}\n"; styleInject(css_248z); // from: https://unpkg.com/three@0.142.0/examples/js/renderers/CSS2DRenderer.js class CSS2DObject extends THREE$1.Object3D { constructor(element = document.createElement('div')) { super(); this.isCSS2DObject = true; this.element = element; this.element.style.position = 'absolute'; this.element.style.userSelect = 'none'; this.element.setAttribute('draggable', false); this.addEventListener('removed', function () { this.traverse(function (object) { if (object.element instanceof Element && object.element.parentNode !== null) { object.element.parentNode.removeChild(object.element); } }); }); } copy(source, recursive) { super.copy(source, recursive); this.element = source.element.cloneNode(true); return this; } } const _vector = new THREE$1.Vector3(); const _viewMatrix = new THREE$1.Matrix4(); const _viewProjectionMatrix = new THREE$1.Matrix4(); const _a = new THREE$1.Vector3(); const _b = new THREE$1.Vector3(); class CSS2DRenderer { constructor(parameters = {}) { const _this = this; let _width, _height; let _widthHalf, _heightHalf; const cache = { objects: new WeakMap() }; const domElement = parameters.element !== undefined ? parameters.element : document.createElement('div'); domElement.style.overflow = 'hidden'; this.domElement = domElement; this.getSize = function () { return { width: _width, height: _height }; }; this.render = function (scene, camera) { if (scene.autoUpdate === true) scene.updateMatrixWorld(); if (camera.parent === null) camera.updateMatrixWorld(); _viewMatrix.copy(camera.matrixWorldInverse); _viewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, _viewMatrix); renderObject(scene, scene, camera); zOrder(scene); }; this.setSize = function (width, height) { _width = width; _height = height; _widthHalf = _width / 2; _heightHalf = _height / 2; domElement.style.width = width + 'px'; domElement.style.height = height + 'px'; }; function renderObject(object, scene, camera) { if (object.isCSS2DObject) { _vector.setFromMatrixPosition(object.matrixWorld); _vector.applyMatrix4(_viewProjectionMatrix); const visible = object.visible === true && _vector.z >= -1 && _vector.z <= 1 && object.layers.test(camera.layers) === true; object.element.style.display = visible === true ? '' : 'none'; if (visible === true) { object.onBeforeRender(_this, scene, camera); const element = object.element; element.style.transform = 'translate(-50%,-50%) translate(' + (_vector.x * _widthHalf + _widthHalf) + 'px,' + (-_vector.y * _heightHalf + _heightHalf) + 'px)'; if (element.parentNode !== domElement) { domElement.appendChild(element); } object.onAfterRender(_this, scene, camera); } const objectData = { distanceToCameraSquared: getDistanceToSquared(camera, object) }; cache.objects.set(object, objectData); } for (let i = 0, l = object.children.length; i < l; i++) { renderObject(object.children[i], scene, camera); } } function getDistanceToSquared(object1, object2) { _a.setFromMatrixPosition(object1.matrixWorld); _b.setFromMatrixPosition(object2.matrixWorld); return _a.distanceToSquared(_b); } function filterAndFlatten(scene) { const result = []; scene.traverse(function (object) { if (object.isCSS2DObject) result.push(object); }); return result; } function zOrder(scene) { const sorted = filterAndFlatten(scene).sort(function (a, b) { if (a.renderOrder !== b.renderOrder) { return b.renderOrder - a.renderOrder; } const distanceA = cache.objects.get(a).distanceToCameraSquared; const distanceB = cache.objects.get(b).distanceToCameraSquared; return distanceA - distanceB; }); const zMax = sorted.length; for (let i = 0, l = sorted.length; i < l; i++) { sorted[i].element.style.zIndex = zMax - i; } } } } // THREE.CSS2DObject = CSS2DObject; // THREE.CSS2DRenderer = CSS2DRenderer; // from: https://github.com/sms-system/conditional-chain // via: https://stackoverflow.com/a/52399890 function cond(chain) { return { if(condition, thenF, elseF) { return cond(condition ? thenF(chain) : elseF ? elseF(chain) : chain); }, chain(f) { return cond(f(chain)); }, end() { return chain; } }; } const DimEnum = Object.freeze({ '2d': '2d', '3d': '3d', 'ar': 'ar', 'vr': 'vr' }); const GraphKindEnum = Object.freeze({ tree: 'tree', web: 'web' }); Object.freeze({ nodes: 'nodes', links: 'links' }); const NodeKindEnum = Object.freeze({ doc: 'doc', media: 'media', template: 'template', zombie: 'zombie' }); const LinkKindEnum = Object.freeze({ fam: 'fam', attr: 'attr', link: 'link', embed: 'embed' }); const CtrlEnum = Object.freeze({ // graph properties kind: 'kind', dim: 'dim', filter: 'filter', fix: 'fix', flip: 'flip', // corresponds to tree's 'flip' button follow: 'follow', glow: 'glow', autosync: 'autosync', // graph actions click: 'click', drag: 'drag', hover: 'hover', select: 'select', data: 'data', save: 'save', sync: 'sync' }); class TreeHouze { constructor(elementWrap, elementGraph, opts) { // cache graph data purpose: // - to be able to redraw the graph without needing data to be given again // - to save lineage/neighbor ids for redraws // - to save fixed node coords this.dataCache = {}; // graph values this.graphWrap = elementWrap; // selection box is appended to this element this.graphDiv = elementGraph; // graph is appended to this div this.nodeRadiusDefault = 6; // node radius / size this.directionalParticles = 4; // number of particles to display on link line this.glowShadowBlur = 40; // 2d 'shadowBlur' property for glow this.fallbackColor = '#FFFFFF'; // fallback node color in case kinds/types aren't working // option custom default this.dagHeight = opts.dagHeight ? opts.dagHeight : 100; this.centerSpeed = opts.centerSpeed ? opts.centerSpeed : 1000; this.isCurrentNode = opts.current ? opts.current : node => { return false; }; // colors this.colors = opts.colors ? opts.colors : { // graph background: '#000011', // node text: '#e6e1e8', // node labels band: '#44434d', // node band current: '#F0C61F', // 'current node' // link link: '#44434d', particle: '#959396' // link particles }; this.nodekinds = opts.nodekinds ? opts.nodekinds : { doc: '#3e5c50', template: '#F8F0E3', zombie: '#959DA5' }; this.nodetypes = opts.nodetypes ? opts.nodetypes : { default: '#3e5c50' }; // this.linkkinds = opts.linkkinds ? opts.linkkinds : { // fam : '', // attr : '', // link : '', // embed: '', // }; // this.linktypes = opts.linktypes ? opts.linktypes : { default: '#44434d' }; // ctrls this.enabled = opts.ctrls.enabled ? opts.ctrls.enabled : true; this.exclude = opts.ctrls.exclude ? opts.ctrls.exclude : []; // graph properties this.isAutoSyncActive = opts.ctrls.autosync ? opts.ctrls.autosync : true; this.dim = opts.ctrls.dim ? opts.ctrls.dim : DimEnum['2d']; this.isFiltered = { nodes: { [NodeKindEnum.doc]: true, // [NodeKindEnum.media]: true, [NodeKindEnum.template]: true, [NodeKindEnum.zombie]: true }, links: { [LinkKindEnum.fam]: true, [LinkKindEnum.attr]: true, [LinkKindEnum.link]: true, [LinkKindEnum.embed]: true } }; this.kind = opts.ctrls.kind ? opts.ctrls.kind : GraphKindEnum.web; // graph actions this.isClickActive = opts.ctrls.click ? opts.ctrls.click : true; this.isDragActive = opts.ctrls.drag ? opts.ctrls.drag : true; this.isFixActive = opts.ctrls.fix ? opts.ctrls.fix : true; this.isFollowActive = opts.ctrls.follow ? opts.ctrls.follow : true; this.isGlowActive = opts.ctrls.glow ? opts.ctrls.glow : true; this.isHoverActive = opts.ctrls.hover ? opts.ctrls.hover : true; this.isSelectActive = opts.ctrls.select ? opts.ctrls.hover : true; // controls this.setupCtrls(); // seems like the y-axis is flipped between 2d and 3d space...not sure why... this.flipYAxis = -1; // init empty graph this.graph = undefined; } //// // ctrls setupCtrls(opts = {}) { if (!this.enabled) { return; } const DEFAULT_CTRLS = { // graph properties kind: this.kind, dim: this.dim, filter: this.isFiltered, fix: this.isFixActive, follow: this.isFollowActive, glow: this.isGlowActive, autosync: this.isAutoSyncActive, // actions click: this.isClickActive, drag: this.isDragActive, hover: this.isHoverActive, select: this.isSelectActive }; const ctrls = merge(DEFAULT_CTRLS, opts); // init ctrl pane if (this.ctrlPane) { this.ctrlPane.dispose(); } // the 'title' key allows the pane to be collapsible this.ctrlPane = new Pane({ title: 'controls', expanded: true }); const tabs = this.ctrlPane.addTab({ pages: [{ title: 'properties' }, { title: 'actions' }] }); //// // graph properties const tabProperties = tabs.pages[0]; // kind if (!this.exclude.includes(CtrlEnum.kind)) { tabProperties.kindInput = tabProperties.addInput(ctrls, 'kind', { options: { 'tree': GraphKindEnum.tree, 'web': GraphKindEnum.web } }); tabProperties.kindInput.on('change', ev => { this.updateKind(ev.value); this.draw(); }); } // dim if (!this.exclude.includes(CtrlEnum.dim)) { tabProperties.dimInput = tabProperties.addInput(ctrls, 'dim', { options: { '2D': DimEnum['2d'], '3D': DimEnum['3d'], 'AR': DimEnum['ar'], 'VR': DimEnum['vr'] } }); tabProperties.dimInput.on('change', ev => { this.updateDim(ev.value); this.draw(); }); } // fix <-> force if (!this.exclude.includes(CtrlEnum.fix)) { tabProperties.fixInput = tabProperties.addInput(ctrls, CtrlEnum.fix); tabProperties.fixInput.on('change', ev => this.updateFixActive(Boolean(ev.value))); } // follow if (!this.exclude.includes(CtrlEnum.follow)) { tabProperties.followInput = tabProperties.addInput(ctrls, CtrlEnum.follow); tabProperties.followInput.on('change', ev => this.updateFollowActive(Boolean(ev.value))); } // glow if (!this.exclude.includes(CtrlEnum.glow)) { tabProperties.glowInput = tabProperties.addInput(ctrls, CtrlEnum.glow); tabProperties.glowInput.on('change', ev => this.updateGlowActive(Boolean(ev.value))); } // auto-sync if (!this.exclude.includes(CtrlEnum.autosync)) { tabProperties.autosyncInput = tabProperties.addInput(ctrls, CtrlEnum.autosync); tabProperties.autosyncInput.on('change', ev => this.updateAutoSyncActive(Boolean(ev.value))); } // flip (tree on the y-axis) if (!this.exclude.includes(CtrlEnum.flip)) { tabProperties.flip = tabProperties.addButton({ title: 'flip' }); tabProperties.flip.on('click', () => this.flip()); tabProperties.flip.disabled = this.kind === GraphKindEnum.web; } // filter if (!this.exclude.includes(CtrlEnum.filter)) { const folderFilter = tabProperties.addFolder({ title: 'filter', expanded: false }); // nodes folderFilter.addBlade({ view: 'text', label: 'node', parse: v => String(v), value: 'kinds', disabled: true }); folderFilter.filterNodeDocInput = folderFilter.addInput(ctrls.filter.nodes, NodeKindEnum.doc); folderFilter.filterNodeDocInput.on('change', ev => { this.updateFilterNodes(NodeKindEnum.doc, Boolean(ev.value)); this.draw(); }); folderFilter.filterNodeTemplateInput = folderFilter.addInput(ctrls.filter.nodes, NodeKindEnum.template); folderFilter.filterNodeTemplateInput.on('change', ev => { this.updateFilterNodes(NodeKindEnum.template, Boolean(ev.value)); this.draw(); }); folderFilter.filterNodeZombieInput = folderFilter.addInput(ctrls.filter.nodes, NodeKindEnum.zombie); folderFilter.filterNodeZombieInput.on('change', ev => { this.updateFilterNodes(NodeKindEnum.zombie, Boolean(ev.value)); this.draw(); }); folderFilter.addSeparator(); folderFilter.addBlade({ view: 'text', label: 'link', parse: v => String(v), value: 'kinds', disabled: true }); // links // todo: fam // folderFilter.filterLinkFamInput = folderFilter.addInput(ctrls.filter.links, LinkKindEnum.fam); // folderFilter.filterLinkFamInput.on('change', (ev) => { // this.updateFilterLinks(LinkKindEnum.fam, Boolean(ev.value)); // this.draw(); // }); // folderFilter.filterLinkFamInput.disabled = (this.kind === KindEnum.web); // attr folderFilter.filterLinkAttrInput = folderFilter.addInput(ctrls.filter.links, LinkKindEnum.attr); folderFilter.filterLinkAttrInput.on('change', ev => { this.updateFilterLinks(LinkKindEnum.attr, Boolean(ev.value)); this.draw(); }); folderFilter.filterLinkAttrInput.disabled = this.kind === GraphKindEnum.tree; // link folderFilter.filterLinkLinkInput = folderFilter.addInput(ctrls.filter.links, LinkKindEnum.link); folderFilter.filterLinkLinkInput.on('change', ev => { this.updateFilterLinks(LinkKindEnum.link, Boolean(ev.value)); this.draw(); }); folderFilter.filterLinkLinkInput.disabled = this.kind === GraphKindEnum.tree; // embed folderFilter.filterLinkEmbedInput = folderFilter.addInput(ctrls.filter.links, LinkKindEnum.embed); folderFilter.filterLinkEmbedInput.on('change', ev => { this.updateFilterLinks(LinkKindEnum.embed, Boolean(ev.value)); this.draw(); }); folderFilter.filterLinkEmbedInput.disabled = this.kind === GraphKindEnum.tree; folderFilter.addSeparator(); } //// // actions const tabActions = tabs.pages[1]; // click if (!this.exclude.includes(CtrlEnum.click)) { tabActions.clickInput = tabActions.addInput(ctrls, CtrlEnum.click); tabActions.clickInput.on('change', ev => this.updateClickActive(Boolean(ev.value))); } // drag if (!this.exclude.includes(CtrlEnum.drag)) { tabActions.dragInput = tabActions.addInput(ctrls, CtrlEnum.drag); tabActions.dragInput.on('change', ev => this.updateDragActive(Boolean(ev.value))); } // hover if (!this.exclude.includes(CtrlEnum.hover)) { tabActions.hoverInput = tabActions.addInput(ctrls, CtrlEnum.hover); tabActions.hoverInput.on('change', ev => this.updateHoverActive(Boolean(ev.value))); } // select if (!this.exclude.includes(CtrlEnum.select)) { tabActions.selectInput = tabActions.addInput(ctrls, CtrlEnum.select); tabActions.selectInput.on('change', ev => this.updateSelectActive(Boolean(ev.value))); } // data if (!this.exclude.includes(CtrlEnum.data) && (!this.exclude.includes(CtrlEnum.save) || !this.exclude.includes(CtrlEnum.sync))) { const folderData = tabActions.addFolder({ title: 'data', expanded: false }); // sync if (!this.exclude.includes(CtrlEnum.sync)) { folderData.sync = folderData.addButton({ title: 'sync' // label: 'sync', }); folderData.sync.on('click', () => this.sync()); } // save if (!this.exclude.includes(CtrlEnum.save)) { folderData.save = folderData.addButton({ title: 'save' // label: 'save', }); folderData.save.on('click', () => this.save()); } } // update ctrl vars if (Object.keys(opts).length > 0) { this.updateCtrls(opts); } } //// // input methods updateCtrls(payload) { for (let [ctrl, value] of Object.entries(payload)) { switch (ctrl) { // graph properties case CtrlEnum.dim: { this.updateDim(value); break; } case CtrlEnum.kind: { this.updateKind(value); break; } case CtrlEnum.filter: { for (const [kind, boolVal] of Object.entries(value.nodes)) { this.updateFilterNodes(kind, boolVal); } for (const [kind, boolVal] of Object.entries(value.links)) { this.updateFilterLinks(kind, boolVal); } break; } case CtrlEnum.fix: { this.updateFixActive(value); break; } case CtrlEnum.follow: { this.updateFollowActive(value); break; } case CtrlEnum.glow: { this.updateGlowActive(value); break; } case CtrlEnum.autosync: { this.updateAutoSyncActive(value); break; } // graph actions case CtrlEnum.click: { this.updateClickActive(value); break; } case CtrlEnum.drag: { this.updateDragActive(value); break; } case CtrlEnum.hover: { this.updateHoverActive(value); break; } case CtrlEnum.select: { this.updateSelectActive(value); break; } default: { console.warn(`invalid ctrl: "${ctrl}" with value: "${JSON.stringify(value)}"`); } } } } // graph properties updateDim(value) { if (!Object.values(DimEnum).includes(value)) { console.warn(`invalid graph 'dim'ension: ${value}`); } else { this.dim = value; } } updateKind(value) { if (!Object.values(GraphKindEnum).includes(value)) { console.warn(`invalid graph 'kind': ${value}`); } else { this.kind = value; } } updateFixActive(value) { this.isFixActive = Boolean(value); if (this.hasData()) { // restick graph nodes for (let node of this.data()['nodes']) { if (this.isFixActive) { this.restick(node); // this.graph.d3Force('center', null); } else { this.unstick(node); } } } } updateFollowActive(value) { this.isFollowActive = Boolean(value); } updateGlowActive(value) { this.isGlowActive = Boolean(value); } updateFilterNodes(filter, value) { if (!Object.values(NodeKindEnum).includes(filter)) { console.warn(`invalid node filter: ${filter}`); } else { this.isFiltered.nodes[filter] = value; } } updateFilterLinks(filter, value) { if (!Object.values(LinkKindEnum).includes(filter)) { console.warn(`invalid link filter: ${filter}`); } else { this.isFiltered.links[filter] = value; } } updateAutoSyncActive(value) { this.isAutoSyncActive = Boolean(value); } // actions updateClickActive(value) { this.isClickActive = Boolean(value); } updateDragActive(value) { this.isDragActive = Boolean(value); } updateHoverActive(value) { this.isHoverActive = Boolean(value); if (!this.isHoverActive) { this.highlightNodes.clear(); this.highlightLinks.clear(); this.hoverNode = null; this.hoverLink = null; } } updateSelectActive(value) { this.isSelectActive = Boolean(value); if (!this.isSelectActive) { this.selectedNodes.clear(); } } //// // main GraphClass() { if (this.dim === DimEnum['2d']) { return ForceGraph; } else if (this.dim === DimEnum['3d']) { return ForceGraph3D; } else if (this.dim === DimEnum['ar']) { return ForceGraphAR; } else if (this.dim === DimEnum['vr']) { return ForceGraphVR; } else { console.error("not a valid graph dimension"); } } draw(data, opts) { // caching if (data !== undefined) { // save this.dataCache = cloneDeep(data); } else { // retrieve data = cloneDeep(this.dataCache); } // error if (!data) { console.error('no graph data'); return; } // apply any filters (node/link) data = this.filter(data); // opts if (opts) { this.nodekinds = cloneDeep(opts.nodekinds); this.nodetypes = cloneDeep(opts.nodetypes); this.setupCtrls(opts.ctrls); } // vars this.selectedNodes = new Set([]); this.highlightNodes = new Set([]); this.highlightLinks = new Set([]); this.hoverNode = null; this.hoverLink = null; // todo: for tree kind // node height vars // this.shifted = []; // this.numSiblingsLeft = []; // hydrate nodes from node ids if (this.is2Dor3D()) { if (this.kind === GraphKindEnum.tree) { this.prepLineage(data); } if (this.kind === GraphKindEnum.web) { this.prepNeighbors(data); } } //// // graph const Graph = cond(this.GraphClass()({ extraRenderers: [new CSS2DRenderer()] })(this.graphDiv) // graph .graphData(data).height(this.graphDiv.parentElement.clientHeight).width(this.graphDiv.parentElement.clientWidth) // node .nodeId('id') // link .linkSource('source').linkTarget('target') // .linkWidth(link => highlightLinks.has(link) ? 4 : 1) .linkColor(() => this.colors.link)) // graph properties // web -> tree .if(this.kind === GraphKindEnum.tree, g => g.dagMode('td').dagLevelDistance(this.dagHeight)) // 2d .if(this.dim === DimEnum['2d'], g => g.nodeCanvasObject((node, ctx) => this.nodePaint(node, ctx))) // 3d / ar / vr .if(this.dim !== DimEnum['2d'], g => g.nodeThreeObject(node => this.nodeClay(node))) // unsupported by 'ar' .if(this.dim !== DimEnum['ar'], g => g.backgroundColor(this.colors.background).nodeLabel('label')) // graph (inter)actions .if(this.is2Dor3D(), g => g.onBackgroundClick(event => { if (this.isSelectActive && !event.shiftKey) { this.selectedNodes.clear(); if (this.dim === DimEnum['3d']) { this.reshape(); } } })).if(this.is2Dor3D(), g => g.onNodeClick((node, event) => this.onClickNode(node, event))).if(this.is2Dor3D(), g => g.onNodeDrag((node, translate) => this.onDragNode(node, translate)).onNodeDragEnd(node => this.onDragEndNode(node))).if(this.is2Dor3D(), g => g.onNodeHover((node, prevNode) => this.onHoverNode(node, prevNode)).onLinkHover((link, prevLink) => this.onHoverLink(link)).linkDirectionalParticles(this.directionalParticles).linkDirectionalParticleWidth(link => this.highlightLinks.has(link) ? 2 : 0).linkDirectionalParticleColor(() => this.colors.particle)).end(); elementResizeDetectorMaker().listenTo(this.graphDiv, function (el) { Graph.width(el.offsetWidth); Graph.height(el.offsetHeight); }); // destroy previous graph if (this.graph !== undefined) { this.graph._destructor(); } // attach new graph this.graph = Graph; // restick nodes (on redraws) if (this.isDragActive && this.isFixActive) { for (let node of this.data()['nodes']) { this.restick(node); } } //// // box selection this.selectBox = undefined; this.selectStartPoint = undefined; // 3d-only this.selectTranslator = undefined; this.pointTopLeft, this.pointBottomRight = undefined; this.cameraPos = undefined; this.selectTranslator = undefined; if (this.is2Dor3D()) { if (this.dim === DimEnum['3d']) { // utility to convert between 2d select box coordinates and 3d graph coordinates this.selectTranslator = new SelectionBox(this.graph.camera(), this.graph.scene()); } // attach listeners this.graphDiv.addEventListener('pointerdown', e => this.onSelectStart(e)); this.graphDiv.addEventListener('pointermove', e => this.onSelect(e)); this.graphDiv.addEventListener('pointerup', e => this.onSelectEnd(e)); } } // 3d quirk methods reshape() { // trigger update of highlighted objects in scene this.graph.nodeThreeObject(this.graph.nodeThreeObject()).linkDirectionalParticles(this.graph.linkDirectionalParticles()).linkDirectionalParticleWidth(this.graph.linkDirectionalParticleWidth()).linkDirectionalParticleColor(this.graph.linkDirectionalParticleColor()); } //// // graph description helper is2Dor3D() { return /\d+/.test(this.dim); } //// // data data() { return this.hasData() ? this.graph.graphData() : undefined; } hasData() { return Boolean(this.graph && Object.keys(this.graph).includes('graphData') && this.graph.graphData()); } prepLineage(data) { // lineage: replace ids with fully rendered graph objects return data.nodes.forEach(node => { // lineage let lineageNodes = []; node.lineage.nodes.forEach(nodeId => { lineageNodes.push(data.nodes.find(renderedNode => renderedNode.id === nodeId)); }); let lineageLinks = []; node.lineage.links.forEach(link => { lineageLinks.push(data.links.find(renderedLink => renderedLink.source === link.source && renderedLink.target === link.target)); }); node.lineage.nodes = lineageNodes; node.lineage.links = lineageLinks; // todo: siblings // this.numSiblingsLeft[node.parent] = node.siblings.length; }); } prepNeighbors(data) { // neighbors: replace ids with fully rendered graph objects return data.nodes.forEach(node => { let neighborNodes = []; node.neighbors.nodes.forEach(nodeId => { neighborNodes.push(data.nodes.find(renderedNode => renderedNode.id === nodeId)); }); let neighborLinks = []; node.neighbors.links.forEach(link => { neighborLinks.push(data.links.find(renderedLink => renderedLink.source === link.source && renderedLink.target === link.target)); }); node.neighbors.nodes = neighborNodes; node.neighbors.links = neighborLinks; }); } filter(data) { let nodeKinds = Object.entries(this.isFiltered.nodes).filter(item => item[1] === true).map(item => item[0]); let linkKinds = Object.entries(this.isFiltered.links).filter(item => item[1] === true).map(item => item[0]); let filteredNodeIds = []; data.nodes = data.nodes.filter(node => { if (nodeKinds.includes(node.kind)) { return true; } else { filteredNodeIds.push(node.id); return false; } }); data.links = data.links.filter(link => { return linkKinds.includes(link.kind) && !filteredNodeIds.includes(link.source) && !filteredNodeIds.includes(link.target); }); return data; } //// // draw helpers // 3d nodeClay(node) { if (!this.isFixActive) { this.unstick(node); } // todo-shift: this shiftNodeHeight() animates more smoothly, but suffers from a race condition // if (this.kind === "tree") { // node.fy = this.shiftNodeHeight(node); // } // prep node vars let radius = this.nodeRadiusDefault; let alpha = 0.75; let labelled = true; // 'ar' + 'vr' are choking on "infinite" recursion in 'lineage'/'neighbors' let res = this.setHoverNode(node, radius, alpha, labelled); radius = res[0]; alpha = res[1]; labelled = res[2]; // calculate color/alpha let color = this.setColorNode(node); // build threejs object // invisible mesh for dragging let handleGeometry = new THREE.SphereGeometry(radius, 32, 16); let handleMaterial = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 }); let handleObj = new THREE.Mesh(handleGeometry, handleMaterial); // node let nodeGeometry = new THREE.SphereGeometry(radius, 32, 16); let nodeMaterial = new THREE.MeshLambertMaterial({ color: color, transparent: true, opacity: alpha }); let nodeObj = new THREE.Mesh(nodeGeometry, nodeMaterial); // band radius = this.selectedNodes.has(node) ? radius * (7 / 5) : radius; let bandGeometry = new THREE.TorusGeometry(radius, 1.5, 16, 100); let bandMaterial = new THREE.MeshLambertMaterial({ color: this.colors.band, transparent: true, opacity: alpha }); let bandObj = new THREE.Mesh(bandGeometry, bandMaterial); // label let label = ''; if (labelled) { label = document.createElement('div'); label.textContent = node.label; label.style.color = this.colors.text; label.style.opacity = this.hoverNode || this.hoverLink ? 1.0 : 0.20; // label.style.z = 0; handleObj.add(new CSS2DObject(label)); } handleObj.add(nodeObj); handleObj.add(bandObj); // todo: randomly rotate node so bands are not all facing the same direction // handleObj.rotation.x += Math.random(); // handleObj.rotation.y += Math.random(); // handleObj.rotation.z += Math.random(); return handleObj; } // 2d nodePaint(node, ctx) { if (!this.isFixActive) { this.unstick(node); } // todo-shift: this shiftNodeHeight() animates more smoothly, but suffers from a race condition // if (this.kind === "tree") { // node.fy = this.shiftNodeHeight(node); // } // prep node vars let radius = this.nodeRadiusDefault; let alpha = 1.0; let labelled = true; let res = this.setHoverNode(node, radius, alpha, labelled); radius = res[0]; alpha = res[1]; labelled = res[2]; // calculate color/alpha let color = this.setColorNode(node); // 'ctx' calls are order-dependent // alpha set ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false); if (this.isGlowActive) { // glow on if (this.isCurrentNode(node)) { ctx.shadowBlur = this.glowShadowBlur; ctx.shadowColor = this.colors.current; } else { // nodekind if (this.nodekinds[node.kind] !== NodeKindEnum.doc) { ctx.shadowColor = this.nodekinds[node.kind]; // nodetype } else { ctx.shadowColor = this.nodetypes[node.type]; } ctx.shadowBlur = node.state; } } ctx.fill(); // glow off ctx.shadowBlur = 0; ctx.shadowColor = ""; // draw band ctx.lineWidth = this.selectedNodes.has(node) ? radius * (4 / 5) : radius * (2 / 5); ctx.strokeStyle = this.colors.band; ctx.stroke(); if (labelled) { // draw label ctx.globalAlpha = this.hoverNode || this.hoverLink ? 1.0 : 0.25; ctx.fillStyle = this.colors.text; ctx.fillText(node.label, node.x + radius + 1, node.y + radius + 1); } // alpha reset ctx.globalAlpha = 1.0; } // shiftNodeHeight(node) { /* node === root */ // if (node.title !== 'O' && !this.shifted.includes(node)) { // const padding = 5; // let areSiblingsLeftEven = (this.numSiblingsLeft[node.parent] % 2) === 1; // let altrntr = areSiblingsLeftEven ? 1 : -1; // node.fy = node.fy + (altrntr * (this.numSiblingsLeft[node.parent] * padding)); // this.numSiblingsLeft[node.parent] -= 1; // this.shifted.push(node); // } // } // draw helpers setColorNode(node) { let color = undefined; let kind = undefined; let type = undefined; const isValidKind = Object.keys(this.nodekinds).includes(node.kind); const isValidType = Object.keys(this.nodetypes).includes(node.type); const isDocKind = node.kind === NodeKindEnum.doc; const hasDefaultType = Object.keys(this.nodetypes).includes('default'); const nodeHasType = Object.keys(node).includes('type'); //// // resolve kind if (!isValidKind) { console.warn(`not a valid nodekind: ${node.kind}`); } else { kind = node.kind; } //// // resolve type if (isDocKind && !isValidType && nodeHasType) { console.warn(`not a valid nodetype: ${node.type}`); } if (isDocKind && !isValidType && hasDefaultType) { type = 'default'; } if (isDocKind && isValidType) { type = node.type; kind = undefined; // reset kind } //// // set color let colorfy = kind !== undefined ? this.nodekinds[kind] : this.nodetypes[type]; if (typeof colorfy === 'string') { color = colorfy; } else if (typeof colorfy === 'function') { color = colorfy(type); } else { console.warn(`nodetype's color must resolve to a hex value: ${colorfy}`); } // if still uncolored, fallback if (color === undefined) { console.warn('using fallback nodetype color'); color = this.fallbackColor; } // done return color; } setHoverNode(node, radius, alpha, labelled) { // hovering node // if (this.isHoverActive && this.hoverNode && node === this.hoverNode) { radius *= 2; alpha = this.dim === DimEnum['2d'] ? 1.0 : 0.75; labelled = false; // non-hovering nodes // } else if (this.isHoverActive && this.hoverNode && node !== this.hoverNode && !this.selectedNodes.has(node) && (this.kind === GraphKindEnum.web && !this.hoverNode.neighbors.nodes.includes(node) || this.kind === GraphKindEnum.tree && !this.hoverNode.lineage.nodes.includes(node)) || // non-hovering links // this.hoverLink && this.hoverLink.source !== node && this.hoverLink.target !== node) { alpha = this.dim === DimEnum['2d'] ? 0.25 : 0.20; labelled = false; // // } else { alpha = this.dim === DimEnum['2d'] ? 1.0 : 0.75; labelled = true; } return [radius, alpha, labelled]; } //// // interactions onClickNode(node, event) { if (this.isClickActive) { if (this.isSelectActive && event.shiftKey) { if (!this.selectedNodes.has(node)) { this.selectedNodes.add(node); } else { this.selectedNodes.delete(node); } } else { this.centerNode(node); } } } onDragNode(node, translate) { if (this.isDragActive) { if (this.isFixActive) { // move dragged node node.fx = node.x + translate.x; node.fy = node.y + translate.y; if (this.dim !== DimEnum['2d']) { node.fz = node.z + translate.z; } if (this.isSelectActive) { // if node wasn't a selected node, update selected nodes if (!this.selectedNodes.has(node)) { this.selectedNodes.clear(); if (this.dim !== DimEnum['2d']) { this.reshape(); } } // move selected nodes alongside dragged node for (let snode of this.selectedNodes) { if (snode !== node) { snode.fx = snode.x + translate.x; snode.fy = snode.y + translate.y; if (this.dim !== DimEnum['2d']) { snode.fz = snode.z + translate.z; } } } } } } } onDragEndNode(node) { if (this.isDragActive) { let draggedNodes = [node]; if (this.isSelectActive) { draggedNodes = draggedNodes.concat(Array.from(this.selectedNodes)); } // fix node (and selected nodes) in place if (this.isFixActive) { for (let dnode of draggedNodes) { this.stick(dnode); } // release node(s) } else { for (let snode of this.selectedNodes) { if (snode !== node) { this.unstick(snode); } } } } } onHoverNode(node, prevNode) { if (this.isHoverActive) { this.highlightNodes.clear(); this.highlightLinks.clear(); if (node) { this.highlightNodes.add(node); if (this.kind === GraphKindEnum.tree) { node.lineage.nodes.forEach(node => this.highlightNodes.add(node)); node.lineage.links.forEach(link => this.highlightLinks.add(link)); } if (this.kind === GraphKindEnum.web) { node.neighbors.nodes.forEach(node => this.highlightNodes.add(node)); node.neighbors.links.forEach(link => this.highlightLinks.add(link)); } } this.hoverNode = node || null; if (this.dim === DimEnum['3d'] && node !== prevNode) { this.reshape(); } } } onHoverLink(link) { if (this.isHoverActive) { this.highlightNodes.clear(); this.highlightLinks.clear(); if (link) { this.highlightLinks.add(link); this.highlightNodes.add(link.source); this.highlightNodes.add(link.target); } this.hoverLink = link || null; if (this.dim === DimEnum['3d']) { this.reshape(); } } } // box selection onSelectStart(evt) { if (this.isSelectActive && evt.shiftKey) { evt.preventDefault(); if (this.dim === DimEnum['3d']) { // hold camera position this.cameraPos = this.graph.cameraPosition(); } // create box selector this.selectBox = document.createElement('div'); this.selectBox.className = 'select-box'; // css: size + coords this.selectBox.style.width = '0px'; this.selectBox.style.height = '0px'; // selected coordinates this.selectStartPoint = { x: evt.clientX, y: evt.clientY }; this.graphWrap.appendChild(this.selectBox); if (this.dim === DimEnum['3d']) { // window <-> graph-coords translation this.selectTranslator.startPoint.set(evt.clientX / window.innerWidth * 2 - 1, -(evt.clientY / window.innerHeight) * 2 + 1, 0.5); } } } onSelect(evt) { if (this.isSelectActive && evt.shiftKey && this.selectBox) { evt.preventDefault(); if (this.dim === DimEnum['3d']) { // hold camera position this.graph.cameraPosition(this.cameraPos); } // css const left = Math.min(this.selectStartPoint.x, evt.clientX); const right = Math.max(this.selectStartPoint.x, evt.clientX); const top = Math.min(this.selectStartPoint.y, evt.clientY); const bottom = Math.max(this.selectStartPoint.y, evt.clientY); // css: size + coords this.selectBox.style.left = left + 'px'; this.selectBox.style.top = top + 'px'; this.selectBox.style.width = right - left + 'px'; this.selectBox.style.height = bottom - top + 'px'; if (this.dim === DimEnum['3d']) { // window <-> graph-coords translation this.selectTranslator.endPoint.set(evt.clientX / window.innerWidth * 2 - 1, -(evt.clientY / window.innerHeight) * 2 + 1, 0.5); } } // just-in-case: cleanup if not selecting if (!evt.shiftKey && this.selectBox) { this.selectBox.remove(); this.selectBox = undefined; } } onSelectEnd(evt) { if (this.isSelectActive && this.selectBox) { evt.preventDefault(); if (this.dim === DimEnum['2d']) { const left = Math.min(this.selectStartPoint.x, evt.clientX); const right = Math.max(this.selectStartPoint.x, evt.clientX); const top = Math.min(this.selectStartPoint.y, evt.clientY); const bottom = Math.max(this.selectStartPoint.y, evt.clientY); const tl = this.graph.screen2GraphCoords(left, top); const br = this.graph.screen2GraphCoords(right, bottom); // set selected nodes const selectedNodes = []; this.data()['nodes'].forEach(node => { if (tl.x < node.x && node.x < br.x && br.y > node.y && node.y > tl.y) { selectedNodes.push(node); } }); this.selectedNodes = new Set(selectedNodes); } if (this.dim === DimEnum['3d']) { // window <-> graph-coords translation this.selectTranslator.endPoint.set(evt.clientX / window.innerWidth * 2 - 1, -(evt.clientY / window.innerHeight) * 2 + 1, 0.5); // todo: keep an eye on this... // set selected nodes this.selectedNodes = new Set(this.selectTranslator.select().filter(item => item.__graphObjType === 'node').map(item => item.__data)); // 'item.__data' should be the rendered node object } // remove selection box if (this.selectBox) { this.selectBox.remove(); this.selectBox = undefined; } if (this.dim === DimEnum['3d']) { // release camera position this.cameraPos = undefined; // render selected nodes this.reshape(); } } } //// // interaction / graph view helpers // data autosync(data) { this.draw(data); return; } save() { return this.dataCache; } sync() { this.draw(this.dataCache); return; } // view flip() { this.data()['nodes'].forEach(node => node.fy *= -1); } centerNode(node) { // 2d if (this.dim === DimEnum['2d']) { this.graph.centerAt(node.x, node.y, this.centerSpeed); // 3d } else { // "aim at node from outside it" const distance = 100; const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); const newPos = node.x || node.y || node.z ? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio } : { x: 0, y: 0, z: distance }; // special case if node is in (0,0,0) this.graph.cameraPosition(newPos, node, this.centerSpeed); } } restick(node) { let cachedNode = this.dataCache['nodes'].find(d => d.id === node.id); if (cachedNode !== undefined && cachedNode.coord !== undefined) { node.fx = cachedNode.coord[0]; node.fy = cachedNode.coord[1]; if (this.dim !== DimEnum['2d']) { node.fy *= this.flipYAxis; node.fz = cachedNode.coord[2]; } } } stick(node) { // graph node.fx = node.x; if (this.kind !== GraphKindEnum.tree) { node.fy = node.y; } if (this.dim !== DimEnum['2d']) { node.fz = node.z; } // coord let cachedNode = this.dataCache['nodes'].find(d => d.id === node.id); if (cachedNode === undefined || cachedNode.coord == undefined) { cachedNode.coord = [0, 0, 0]; } cachedNode.coord[0] = node.x; cachedNode.coord[1] = node.y; if (this.dim !== DimEnum['2d']) { cachedNode.coord[1] *= this.flipYAxis; cachedNode.coord[2] = node.z; } } unstick(node) { // release node position if 'fix' option is off node.fx = undefined; if (this.kind !== GraphKindEnum.tree) { node.fy = undefined; } if (this.dim !== DimEnum['2d']) { node.fz = undefined; } } } export { TreeHouze as default };