treehouze
Version:
Graph utilities for handling and housing markdown wikis -- and (semantic) trees!
1,375 lines (1,311 loc) • 65.8 kB
JavaScript
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