UNPKG

treehouze

Version:

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

1,375 lines (1,311 loc) 65.8 kB
import { merge, cloneDeep } from 'lodash'; import 'aframe'; import * as THREE$1 from 'three'; import { SelectionBox } from 'three/addons/interactive/SelectionBox.js'; import * as d3 from 'd3'; import { forceZ } from 'd3-force-3d'; 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 { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; 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', hybrid: 'hybrid' }); 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', follow: 'follow', glow: 'glow', background: 'background', autosync: 'autosync', flip: 'flip', // corresponds to tree's 'flip' button // graph actions click: 'click', drag: 'drag', hover: 'hover', select: 'select', data: 'data', save: 'save', sync: 'sync' }); // todo: 3d glow // https://x.com/ULuIQ12/status/1818665159084106201 // https://github.com/ULuIQ12/webgpu-tsl-linkedparticles/blob/main/src/lib/elements/LinkedParticles.ts // - bloomPass: https://github.com/vasturiano/3d-force-graph/blob/master/example/bloom-effect/index.html // - vasturiano says it's all or nothing -- can't apply glow differently to each node... // - https://github.com/vasturiano/3d-force-graph/issues/421 // - (someone else mentions the link below) // - it must be possible: // - https://github.com/mrdoob/three.js/blob/master/examples/webgl_postprocessing_unreal_bloom_selective.html // - https://threejs.org/examples/?q=bloom#webgl_postprocessing_unreal_bloom_selective // - another example (fake bloom pass): // - https://discourse.threejs.org/t/how-to-add-a-selective-glow-effect-that-works-with-dark-colors-too/59303/3 // - https://codepen.io/boytchev/pen/ExdmvxE 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: '#1e1e1e', // 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.isBgDark = opts.ctrls.background !== undefined ? opts.ctrls.background : false; 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 background: this.isBgDark ? 'dark' : 'light', 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, 'hybrid': GraphKindEnum.hybrid } }); 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)); this.draw(); }); } // background if (!this.exclude.includes(CtrlEnum.background)) { tabProperties.backgroundInput = tabProperties.addInput(ctrls, CtrlEnum.background, { options: { 'light': 'light', 'dark': 'dark' } }); tabProperties.backgroundInput.on('change', ev => { this.updateBgDark(ev.value === 'dark'); this.draw(); }); } // 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.background: { this.updateBgDark(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); } updateBgDark(value) { this.isBgDark = 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); // tree mode: only fam links (dagMode requires acyclic structure) if (this.kind === GraphKindEnum.tree) { data.links = data.links.filter(link => link.kind === 'fam'); // identify nodes disconnected from tree + pin below leaves const treeLinked = new Set(); data.links.forEach(l => { treeLinked.add(typeof l.source === 'object' ? l.source.id : l.source); treeLinked.add(typeof l.target === 'object' ? l.target.id : l.target); }); data.nodes.reduce((mx, nd) => treeLinked.has(nd.id) ? Math.max(mx, nd.treeDepth || 0) : mx, 0); data.nodes.forEach(nd => { nd._treeOrphan = !treeLinked.has(nd.id); }); } // 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); } if (this.kind === GraphKindEnum.hybrid) { this.prepLineage(data); this.prepNeighbors(data); } } //// // 3D web: seed on Fibonacci sphere (connected + isolates separately) if (this.kind === GraphKindEnum.web && this.dim !== DimEnum['2d']) { const golden = Math.PI * (3 - Math.sqrt(5)); const connected = data.nodes.filter(nd => !nd.isIsolate); const isolates = data.nodes.filter(nd => nd.isIsolate); const spreadC = Math.max(50, Math.sqrt(connected.length) * 8); connected.forEach((node, i) => { const n = connected.length; const phi = Math.acos(1 - 2 * (i + 0.5) / n); const theta = i * golden; node.x = spreadC * Math.sin(phi) * Math.cos(theta); node.y = spreadC * Math.cos(phi); node.z = spreadC * Math.sin(phi) * Math.sin(theta); }); const spreadI = spreadC * 0.6; // isolates in inner sphere isolates.forEach((node, i) => { const n = isolates.length; const phi = Math.acos(1 - 2 * (i + 0.5) / n); const theta = i * golden; node.x = spreadI * Math.sin(phi) * Math.cos(theta); node.y = spreadI * Math.cos(phi); node.z = spreadI * Math.sin(phi) * Math.sin(theta); }); } // 3D hybrid: seed Z positions using the same funnel shape as 2D // (golden angle distribution breaks the z=0 symmetry) if (this.kind === GraphKindEnum.hybrid && this.dim !== DimEnum['2d']) { const scl = Math.max(1, Math.sqrt(data.nodes.length / 100)); const bh = this.dagHeight * scl; const mxD = data.nodes.reduce((mx, nd) => !nd.isOrphan && !nd.isIsolate ? Math.max(mx, nd.treeDepth || 0) : mx, 0); const golden = Math.PI * (3 - Math.sqrt(5)); data.nodes.forEach((node, i) => { const depth = node.isOrphan || node.isIsolate ? mxD : node.treeDepth || 0; const t = mxD > 0 ? depth / mxD : 0; // same funnel formula as 2D — seed at ~60% of boundary const hw = (10 + 300 * Math.pow(t, 0.6)) * scl * 0.6; const angle = i * golden; node.x = hw * Math.cos(angle); node.z = hw * Math.sin(angle); // y: negate so root is at top (three.js Y-up) if (node.isIsolate) node.y = -(mxD + 2.0) * bh;else if (node.isOrphan) node.y = -(mxD + 1.5) * bh;else node.y = -(depth + 1) * bh; }); } // 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(link => { if (this.hoverNode || this.hoverLink) { return this.highlightLinks.has(link) ? this.colors.link : this.colors.link + '15'; // 8% alpha } // web: very subtle links so clusters/nodes are the focus if (this.kind === GraphKindEnum.web) { return this.colors.link + '30'; // 19% alpha } return this.colors.link + '88'; // 53% alpha }).linkWidth(link => { if (this.kind === GraphKindEnum.web) { if (this.hoverNode || this.hoverLink) { return this.highlightLinks.has(link) ? 1.5 : 0.2; } return 0.3; } return 1; })) // graph properties // web -> tree .if(this.kind === GraphKindEnum.tree, g => { const levelDist = this.dagHeight * 1.8; const is3D = this.dim !== DimEnum['2d']; data.nodes.reduce((mx, nd) => !nd._treeOrphan ? Math.max(mx, nd.treeDepth || 0) : mx, 0); // pin orphans one level below the deepest leaf // use onEngineTick so this runs AFTER dagMode's position constraint return g.dagMode('td').dagLevelDistance(levelDist).onEngineTick(() => { let deepest = is3D ? Infinity : -Infinity; data.nodes.forEach(nd => { if (!nd._treeOrphan && isFinite(nd.y)) { if (is3D) deepest = Math.min(deepest, nd.y);else deepest = Math.max(deepest, nd.y); } }); if (!isFinite(deepest)) return; const orphanY = is3D ? deepest - levelDist : deepest + levelDist; data.nodes.forEach(nd => { if (nd._treeOrphan) { nd.fy = orphanY; nd.y = orphanY; } }); }); }) // web forces (obsidian-like clustering) // clusters form from contrast: charge pushes apart, links hold neighbors together .if(this.kind === GraphKindEnum.web, g => { const is3D = this.dim !== DimEnum['2d']; const degree = {}; data.links.forEach(l => { const s = typeof l.source === 'object' ? l.source.id : l.source; const t = typeof l.target === 'object' ? l.target.id : l.target; degree[s] = (degree[s] || 0) + 1; degree[t] = (degree[t] || 0) + 1; }); data.nodes.forEach(n => { n._degree = degree[n.id] || 0; }); // stay close to library defaults for smooth interaction // only mild charge increase + link distance for spacing const chg = is3D ? -120 : -60; const lDist = is3D ? 50 : 50; return g.d3Force('charge', d3.forceManyBody().strength(chg)).d3Force('link', d3.forceLink().id(d => d.id).distance(lDist)).d3Force('collide', null) // isolates: pull to center (no links hold them) .d3Force('x', d3.forceX(0).strength(n => n._degree === 0 ? 0.02 : 0)).d3Force('y', d3.forceY(0).strength(n => n._degree === 0 ? 0.02 : 0)); }).if(this.kind === GraphKindEnum.web && this.dim !== DimEnum['2d'], g => { // separate connected and isolate nodes for even sphere distribution const golden = Math.PI * (3 - Math.sqrt(5)); const connected = data.nodes.filter(nd => !nd.isIsolate); const isolates = data.nodes.filter(nd => nd.isIsolate); const nodePhi = {}; const nodeTheta = {}; connected.forEach((node, i) => { const n = connected.length; nodePhi[node.id] = Math.acos(1 - 2 * (i + 0.5) / n); nodeTheta[node.id] = i * golden; }); isolates.forEach((node, i) => { const n = isolates.length; nodePhi[node.id] = Math.acos(1 - 2 * (i + 0.5) / n); nodeTheta[node.id] = i * golden; }); // 3D: replace forceCenter with weak forceX/Y/Z anchoring // (forceCenter shifts ALL positions on drag, causing whole-graph movement) return g.d3Force('center', null).d3Force('x', d3.forceX(0).strength(n => (n._degree || 0) === 0 ? 0.02 : 0)).d3Force('y', d3.forceY(0).strength(n => (n._degree || 0) === 0 ? 0.02 : 0)).d3Force('z', forceZ(0).strength(n => (n._degree || 0) === 0 ? 0.02 : 0)).d3Force('sphere', alpha => { // skip during drag for smooth interaction if (this._isDragging3D) return; // steer nodes toward assigned sphere directions, scaled by alpha // so it decays with the rest of the simulation and nodes can settle data.nodes.forEach(node => { const phi = nodePhi[node.id]; const theta = nodeTheta[node.id]; if (phi === undefined) return; const x = node.x || 0, y = node.y || 0, z = node.z || 0; const d = Math.sqrt(x * x + y * y + z * z); if (d < 1) return; const tx = d * Math.sin(phi) * Math.cos(theta); const ty = d * Math.cos(phi); const tz = d * Math.sin(phi) * Math.sin(theta); const s = (node.isIsolate ? 0.1 : 0.05) * Math.sqrt(alpha); node.vx += (tx - x) * s; node.vy += (ty - y) * s; node.vz += (tz - z) * s; }); }); }) // hybrid forces (tree hierarchy + web overlaid) .if(this.kind === GraphKindEnum.hybrid, g => { const n = data.nodes.length; const scale = Math.max(1, Math.sqrt(n / 100)); const bandHeight = this.dagHeight * scale; const maxTreeDepth = data.nodes.reduce((mx, nd) => { return !nd.isOrphan && !nd.isIsolate ? Math.max(mx, nd.treeDepth || 0) : mx; }, 0); // parent lookup + child counts for sibling clustering const nodeById = {}; const childCount = {}; data.nodes.forEach(nd => { nodeById[nd.id] = nd; if (nd.treeParent) { childCount[nd.treeParent] = (childCount[nd.treeParent] || 0) + 1; } }); // y-target: tree nodes use full bands; orphans/isolates tuck close below // in 3D, negate Y so root is at top (three.js Y-up) const flip = this.dim !== DimEnum['2d'] ? -1 : 1; const yTarget = d => { if (d.isIsolate) return flip * (maxTreeDepth + 2.0) * bandHeight; if (d.isOrphan) return flip * (maxTreeDepth + 1.5) * bandHeight; return flip * (d.treeDepth + 1) * bandHeight; }; const is3D = this.dim !== DimEnum['2d']; // 3D: assign each node a target angle around the Y-axis (golden angle) // The force rotates each node's natural radial distance into (X,Z), // so the total spread matches 2D but is distributed circularly. const golden = Math.PI * (3 - Math.sqrt(5)); const nodeAngle = {}; if (is3D) { data.nodes.forEach((node, i) => { nodeAngle[node.id] = i * golden; }); } return g.dagMode(null).d3Force('y', d3.forceY(yTarget).strength(0.85)).d3Force('x', d3.forceX(0).strength(0.03)).d3Force('charge', d3.forceManyBody().strength(is3D ? -38 * scale : -25 * scale)).d3Force('link', d3.forceLink().id(d => d.id).distance(l => l.kind === 'fam' ? 0.7 * bandHeight : 1.5 * bandHeight).strength(l => l.kind === 'fam' ? 0.7 : 0.15)).d3Force('cluster', alpha => { // pull each tree node toward its parent's x (X only — same as 2D) data.nodes.forEach(node => { if (node.treeParent && !node.isOrphan && !node.isIsolate) { const parent = nodeById[node.treeParent]; if (parent && isFinite(parent.x)) { const siblings = childCount[node.treeParent] || 1; const strength = 0.3 / Math.pow(siblings, 0.4); node.vx += (parent.x - node.x) * strength * alpha; } } }); }).d3Force('zspread', alpha => { // Z targets = actual X extent at each depth × sin(angle) // This dynamically matches Z spread to X spread → circular if (!is3D) return; // compute max |x| per depth level const xExtent = {}; data.nodes.forEach(node => { const depth = node.isOrphan || node.isIsolate ? maxTreeDepth : node.treeDepth || 0; const ax = Math.abs(node.x || 0); if (!xExtent[depth] || ax > xExtent[depth]) xExtent[depth] = ax; }); data.nodes.forEach(node => { const angle = nodeAngle[node.id]; if (angle === undefined) return; const depth = node.isOrphan || node.isIsolate ? maxTreeDepth : node.treeDepth || 0; const R = xExtent[depth] || 1; // circular budget: nodes near x=0 get full Z range, // nodes at edge get small Z → x² + z² ≤ R² const frac = Math.min(Math.abs(node.x || 0) / R, 1); const maxZ = R * Math.sqrt(1 - frac * frac); const targetZ = maxZ * Math.sin(angle); node.vz += (targetZ - (node.z || 0)) * 0.03; }); }).d3Force('funnel', alpha => { // X-only funnel — identical to 2D behavior data.nodes.forEach(node => { const depth = node.isOrphan || node.isIsolate ? maxTreeDepth : node.treeDepth || 0; const t = maxTreeDepth > 0 ? depth / maxTreeDepth : 0; const hw = (10 + 300 * Math.pow(t, 0.6)) * scale; const dx = node.x; if (Math.abs(dx) > hw) { node.vx -= (dx - Math.sign(dx) * hw) * 0.15 * alpha; } }); }); }) // hybrid link styling (2d only) .if(this.kind === GraphKindEnum.hybrid && this.dim === DimEnum['2d'], g => g.linkLineDash(link => link.kind === 'fam' ? null : [2, 4]).linkCurvature(link => link.kind === 'fam' ? 0 : 0.2).linkColor(link => { const isTree = link.kind === 'fam'; if (this.hoverNode || this.hoverLink) { if (this.highlightLinks.has(link)) { return isTree ? this.colors.link : this.colors.link + '99'; } return isTree ? this.colors.link + '20' : this.colors.link + '10'; } return isTree ? this.colors.link : this.colors.link + '66'; }).linkWidth(link => { if (this.highlightLinks.has(link)) return 2; return link.kind === 'fam' ? 1 : 0.5; })) // hybrid link styling (3d) .if(this.kind === GraphKindEnum.hybrid && this.dim !== DimEnum['2d'], g => g.linkColor(link => { const isTree = link.kind === 'fam'; if (this.hoverNode || this.hoverLink) { if (this.highlightLinks.has(link)) { return isTree ? this.colors.link : this.colors.link + '99'; } return isTree ? this.colors.link + '20' : this.colors.link + '10'; } return isTree ? this.colors.link : this.colors.link + '44'; }).linkWidth(link => { if (this.highlightLinks.has(link)) return 2; return link.kind === 'fam' ? 1 : 0.3; })) // 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.isBgDark ? '#000011' : 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(); // faster settling after drag in 3D (applied directly, not through cond chain) if (this.kind === GraphKindEnum.web && this.dim !== DimEnum['2d']) { Graph.d3AlphaDecay(0.08); } 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); } } // Set up selective bloom for 3D mode if (this.dim === DimEnum['3d'] && this.isGlowActive) { const composer = this.graph.postProcessingComposer(); // upgrade render targets to float so emissive HDR values are preserved const size = this.graph.renderer().getSize(new THREE.Vector2()); const hdrRT = new THREE.WebGLRenderTarget(size.x, size.y, { type: THREE.HalfFloatType }); composer.renderTarget1.dispose(); composer.renderTarget2.dispose(); composer.renderTarget1 = hdrRT; composer.renderTarget2 = hdrRT.clone(); composer.writeBuffer = composer.renderTarget1; composer.readBuffer = composer.renderTarget2; const bloomPass = new UnrealBloomPass(new THREE.Vector2(this.graphDiv.clientWidth, this.graphDiv.clientHeight), 1.5, // strength 0.8, // radius 0.8 // threshold ); composer.addPass(bloomPass); // dim ambient light so emissive glow stands out const scene = this.graph.scene(); scene.traverse(obj => { if (obj.isLight && obj.type === 'AmbientLight') { obj.color.set(0x222222); } }); } //// // 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 // try-catch: force-graph-3d can throw when old three.js objects are replaced mid-frame try { this.graph.nodeThreeObject(this.graph.nodeThreeObject()).linkDirectionalParticles(this.graph.linkDirectionalParticles()).linkDirectionalParticleWidth(this.graph.linkDirectionalParticleWidth()).linkDirectionalParticleColor(this.graph.linkDirectionalParticleColor()); } catch (e) { // stale three.js object reference — safe to ignore, next frame will recover } } //// // 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 => { const found = data.nodes.find(renderedNode => renderedNode.id === nodeId); if (found) neighborNodes.push(found); }); let neighborLinks = []; node.neighbors.links.forEach(link => { const found = data.links.find(renderedLink => renderedLink.source === link.source && renderedLink.target === link.target || renderedLink.source === link.target && renderedLink.target === link.source); if (found) neighborLinks.push(found); }); 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; // web: scale node size by degree (hub nodes larger) if (this.kind === GraphKindEnum.web && node._degree > 0) { radius = this.nodeRadiusDefault + Math.sqrt(node._degree) * 2; } let alpha = 0.9; 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; const shouldGlow3D = this.isGlowActive && (this.isCurrentNode(node) || this.hoverNode && node === this.hoverNode || node.glow); if (shouldGlow3D) { let emissiveIntensity = 3.0; if (node.glow && !this.isCurrentNode(node) && !(this.hoverNode && node === this.hoverNode)) { emissiveIntensity = Math.max(0.2, node.glow) * 3.0; } nodeMaterial = new THREE.MeshStandardMaterial({ color: color, emissive: new THREE.Color(color), emissiveIntensity: emissiveIntensity, transparent: true, opacity: alpha }); } else { // non-glowing nodes use basic material (invisible to bloom pass) nodeMaterial = new THREE.MeshBasicMaterial({ color: this.isGlowActive ? new THREE.Color(color).multiplyScalar(0.75) : 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.08; // 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