@jonobr1/force-directed-graph
Version:
GPU supercharged attraction-graph visualizations for the web built on top of Three.js
1,060 lines (944 loc) • 30.8 kB
JavaScript
import { Color, Group, RepeatWrapping, Vector2, Vector3 } from 'three';
import { GPUComputationRenderer } from 'three/examples/jsm/misc/GPUComputationRenderer.js';
import { clamp, each, getPotSize, rgbToIndex } from './math.js';
import simulation from './shaders/simulation.js';
import { Points } from './points.js';
import { Links } from './links.js';
import { Labels } from './labels.js';
import { Registry } from './registry.js';
import { Hit } from './hit.js';
import { TextureWorkerManager } from './texture-worker-manager.js';
const color = new Color();
const position = new Vector3();
const size = new Vector2();
const drawingBufferSize = new Vector2();
const LineCaps = ['round', 'butt', 'square'];
const LineCapsMap = {
round: 0,
butt: 1,
square: 2,
};
const DEFAULT_LABEL_FONT_FAMILY = 'Arial, sans-serif';
const buffers = {
int: new Uint8ClampedArray(4),
float: new Float32Array(4),
};
function buildLinkTextureData(preparedLinks, nodeAmount, size) {
const totalElements = size * size;
const linksData = new Float32Array(totalElements * 4);
const linkRangesData = new Float32Array(totalElements * 4);
const linksByNode = Array.from({ length: nodeAmount }, () => []);
const packedLinks = [];
for (let i = 0; i < preparedLinks.length; i++) {
const link = preparedLinks[i];
const sourceIndex = link.sourceIndex;
const targetIndex = link.targetIndex;
const isValid =
Number.isInteger(sourceIndex) &&
Number.isInteger(targetIndex) &&
sourceIndex >= 0 &&
targetIndex >= 0 &&
sourceIndex < nodeAmount &&
targetIndex < nodeAmount;
if (!isValid) {
continue;
}
linksByNode[sourceIndex].push(link);
if (targetIndex !== sourceIndex) {
linksByNode[targetIndex].push(link);
}
}
for (let i = 0; i < nodeAmount; i++) {
const rangeOffset = i * 4;
const incident = linksByNode[i];
linkRangesData[rangeOffset + 0] = packedLinks.length;
linkRangesData[rangeOffset + 1] = incident.length;
for (let j = 0; j < incident.length; j++) {
packedLinks.push(incident[j]);
}
}
if (packedLinks.length > totalElements) {
throw new Error(
`Packed links (${packedLinks.length}) exceed texture capacity (${totalElements}).`,
);
}
for (let i = 0; i < packedLinks.length; i++) {
const link = packedLinks[i];
const sourceIndex = link.sourceIndex;
const targetIndex = link.targetIndex;
const linkOffset = i * 4;
linksData[linkOffset + 0] = (sourceIndex % size) / size;
linksData[linkOffset + 1] = Math.floor(sourceIndex / size) / size;
linksData[linkOffset + 2] = (targetIndex % size) / size;
linksData[linkOffset + 3] = Math.floor(targetIndex / size) / size;
}
return {
linksData,
linkRangesData,
packedLinkAmount: packedLinks.length,
};
}
class ForceDirectedGraph extends Group {
ready = false;
/**
* @param {THREE.WebGLRenderer} renderer - the three.js renderer referenced to create the render targets
* @param {Object} [data] - optional data to automatically set the data of the graph
*/
constructor(renderer, data) {
super();
this.userData.registry = new Registry();
this.userData.renderer = renderer;
this.userData.uniforms = {
decay: { value: 1 },
alpha: { value: 1 },
is2D: { value: false },
time: { value: 0 },
size: { value: 64 },
maxSpeed: { value: 10 },
timeStep: { value: 1 },
damping: { value: 0.7 },
repulsion: { value: -0.3 },
springLength: { value: 2 },
stiffness: { value: 0.1 },
gravity: { value: 0.1 },
pinStrength: { value: 0.0 },
nodeRadius: { value: 1 },
nodeScale: { value: 8 },
sizeAttenuation: { value: true },
frustumSize: { value: 100 },
linksInheritColor: { value: false },
labelsInheritColor: { value: false },
pointsInheritColor: { value: true },
pointColor: { value: new Color(1, 1, 1) },
linkColor: { value: new Color(1, 1, 1) },
labelColor: { value: new Color(0, 0, 0) },
linecap: { value: LineCapsMap.round },
linewidth: { value: 1 },
opacity: { value: 1 },
pixelRatio: { value: 1 },
resolution: { value: new Vector2(1, 1) },
uBeginning: { value: 0 },
uEnding: { value: 1 },
uNodeAmount: { value: 0 },
obscurity: { value: 0 },
labelAlignment: { value: 0 },
labelBaseline: { value: 1 },
labelFontSize: { value: 24 },
labelNear: { value: 0 },
labelOffset: { value: new Vector2(0, 0) },
};
this.userData.labelFontFamily = DEFAULT_LABEL_FONT_FAMILY;
this.userData.hit = new Hit(this);
this.userData.workerManager = new TextureWorkerManager();
if (data) {
this.set(data);
}
}
static getPotSize = getPotSize;
static Properties = [
'decay',
'alpha',
'is2D',
'time',
'size',
'maxSpeed',
'timeStep',
'damping',
'repulsion',
'springLength',
'stiffness',
'gravity',
'pinStrength',
'nodeRadius',
'nodeScale',
'sizeAttenuation',
'frustumSize',
'linksInheritColor',
'labelsInheritColor',
'pointsInheritColor',
'pointColor',
'linkColor',
'labelColor',
'linecap',
'linewidth',
'opacity',
'blending',
'obscurity',
];
/**
* @param {Object} data - Object with nodes and links properties based on https://observablehq.com/@d3/force-directed-graph-component
* @param {Function} callback
* @description Set the data to an instance of force directed graph. Because of the potential large amount of data this function runs on a request animation frame and returns a promise (or a passed callback) to give indication when the graph is ready to be rendered.
* @returns {Promise}
*/
set(data, callback) {
const scope = this;
let { gpgpu, registry, renderer, uniforms } = this.userData;
let packedLinkAmount = 0;
this.ready = false;
this.userData.data = data;
// Reset all properties
registry.clear();
// Dispose of all previous gpgpu data
if (gpgpu) {
for (let i = 0; i < gpgpu.variables.length; i++) {
const variable = gpgpu.variables[i];
for (let j = 0; j < variable.renderTargets.length; j++) {
const renderTarget = variable.renderTargets[j];
renderTarget.dispose();
}
variable.initialValueTexture.dispose();
variable.material.dispose();
}
}
// Reset points and links
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
this.remove(child);
if ('dispose' in child) {
child.dispose();
}
}
this.userData.labels = null;
// Initialize new properties
const size = getPotSize(Math.max(data.nodes.length, data.links.length * 2));
uniforms.size.value = size;
gpgpu = new GPUComputationRenderer(size, size, renderer);
const textures = {
positions: gpgpu.createTexture(),
velocities: gpgpu.createTexture(),
links: gpgpu.createTexture(),
linkRanges: gpgpu.createTexture(),
targetPositions: gpgpu.createTexture(),
};
const variables = {
positions: gpgpu.addVariable(
'texturePositions',
simulation.positions,
textures.positions,
),
velocities: gpgpu.addVariable(
'textureVelocities',
simulation.velocities,
textures.velocities,
),
};
this.userData.gpgpu = gpgpu;
this.userData.variables = variables;
this.userData.textures = textures;
return (
register()
.then(fill)
// TODO: Add a sort here for future simulation methods
.then(setup)
.then(generate)
.then(complete)
.catch((error) => {
console.warn('Force Directed Graph:', error);
})
);
function register() {
return each(data.nodes, (node, i) => {
registry.set(i, node);
});
}
async function fill() {
const { workerManager } = scope.userData;
const preparedLinks = data.links.map((link) => {
const sourceIndex = registry.get(link.source);
const targetIndex = registry.get(link.target);
link.sourceIndex = sourceIndex;
link.targetIndex = targetIndex;
return {
...link,
sourceIndex,
targetIndex,
};
});
const nodeDegrees = new Array(data.nodes.length).fill(0);
const nodeAdjacency = Array.from({ length: data.nodes.length }, () => []);
for (let i = 0; i < preparedLinks.length; i++) {
const link = preparedLinks[i];
if (
!Number.isInteger(link.sourceIndex) ||
!Number.isInteger(link.targetIndex) ||
link.sourceIndex < 0 ||
link.targetIndex < 0 ||
link.sourceIndex >= data.nodes.length ||
link.targetIndex >= data.nodes.length
) {
continue;
}
nodeDegrees[link.sourceIndex] += 1;
nodeDegrees[link.targetIndex] += 1;
nodeAdjacency[link.sourceIndex].push(link.targetIndex);
if (link.targetIndex !== link.sourceIndex) {
nodeAdjacency[link.targetIndex].push(link.sourceIndex);
}
}
scope.userData.nodeDegrees = nodeDegrees;
scope.userData.nodeAdjacency = nodeAdjacency;
// Initialize worker if not already done
if (!workerManager.isReady()) {
await workerManager.init();
}
// Try worker-based processing first
if (workerManager.isReady()) {
try {
const result = await workerManager.processTextures({
nodes: data.nodes,
links: preparedLinks,
textureSize: size,
frustumSize: uniforms.frustumSize.value,
});
// Copy results to texture data
textures.positions.image.data.set(result.positions);
textures.links.image.data.set(result.links);
textures.linkRanges.image.data.set(result.linkRanges);
packedLinkAmount = result.packedLinkAmount;
fillTargetPositions();
console.log(
`Texture processing completed in ${result.processingTime.toFixed(2)}ms using ${workerManager.isWasmAvailable() ? 'WASM' : 'JavaScript'}`,
);
return Promise.resolve();
} catch (error) {
console.warn(
'Worker processing failed, falling back to main thread:',
error,
);
// Fall through to main thread processing
}
}
// Fallback to main thread processing
return fillMainThread(preparedLinks);
}
function fillMainThread(preparedLinks) {
const linkTextureData = buildLinkTextureData(
preparedLinks,
data.nodes.length,
size,
);
textures.links.image.data.set(linkTextureData.linksData);
textures.linkRanges.image.data.set(linkTextureData.linkRangesData);
packedLinkAmount = linkTextureData.packedLinkAmount;
return each(
textures.positions.image.data,
(_, i) => {
const k = i / 4;
const x = Math.random() * 2 - 1;
const y = Math.random() * 2 - 1;
const z = Math.random() * 2 - 1;
if (k < data.nodes.length) {
const node = data.nodes[k];
textures.positions.image.data[i + 0] =
typeof node.x !== 'undefined' ? node.x : x;
textures.positions.image.data[i + 1] =
typeof node.y !== 'undefined' ? node.y : y;
textures.positions.image.data[i + 2] =
typeof node.z !== 'undefined' ? node.z : z;
textures.positions.image.data[i + 3] = node.isStatic ? 1 : 0;
} else {
// Throw all outside "extraneous" nodes generated by texture far far away.
textures.positions.image.data[i + 0] =
uniforms.frustumSize.value * 10;
textures.positions.image.data[i + 1] =
uniforms.frustumSize.value * 10;
textures.positions.image.data[i + 2] =
uniforms.frustumSize.value * 10;
textures.positions.image.data[i + 3] =
uniforms.frustumSize.value * 10;
}
},
4,
).then(fillTargetPositions);
}
function fillTargetPositions() {
for (let k = 0; k < data.nodes.length; k++) {
const node = data.nodes[k];
const i = k * 4;
const hasX = typeof node.x !== 'undefined';
const hasY = typeof node.y !== 'undefined';
const hasZ = typeof node.z !== 'undefined';
const definedCount = (hasX ? 1 : 0) + (hasY ? 1 : 0) + (hasZ ? 1 : 0);
const hasTarget = definedCount >= 2 ? 1.0 : 0.0;
textures.targetPositions.image.data[i + 0] = hasTarget
? (node.x ?? 0)
: 0;
textures.targetPositions.image.data[i + 1] = hasTarget
? (node.y ?? 0)
: 0;
textures.targetPositions.image.data[i + 2] = hasTarget
? (node.z ?? 0)
: 0;
textures.targetPositions.image.data[i + 3] = hasTarget;
}
}
function setup() {
return new Promise((resolve, reject) => {
gpgpu.setVariableDependencies(variables.positions, [
variables.positions,
variables.velocities,
]);
gpgpu.setVariableDependencies(variables.velocities, [
variables.velocities,
variables.positions,
]);
variables.positions.material.uniforms.is2D = uniforms.is2D;
variables.positions.material.uniforms.timeStep = uniforms.timeStep;
variables.velocities.material.uniforms.alpha = uniforms.alpha;
variables.velocities.material.uniforms.is2D = uniforms.is2D;
variables.velocities.material.uniforms.size = uniforms.size;
variables.velocities.material.uniforms.time = uniforms.time;
variables.velocities.material.uniforms.nodeRadius = uniforms.nodeRadius;
variables.velocities.material.uniforms.nodeAmount = {
value: data.nodes.length,
};
variables.velocities.material.uniforms.uBeginning = uniforms.uBeginning;
variables.velocities.material.uniforms.uEnding = uniforms.uEnding;
uniforms.uNodeAmount.value = data.nodes.length;
variables.velocities.material.uniforms.edgeAmount = {
value: packedLinkAmount,
};
variables.velocities.material.uniforms.maxSpeed = uniforms.maxSpeed;
variables.velocities.material.uniforms.timeStep = uniforms.timeStep;
variables.velocities.material.uniforms.damping = uniforms.damping;
variables.velocities.material.uniforms.repulsion = uniforms.repulsion;
variables.velocities.material.uniforms.textureLinks = {
value: textures.links,
};
variables.velocities.material.uniforms.textureLinkRanges = {
value: textures.linkRanges,
};
variables.velocities.material.uniforms.springLength =
uniforms.springLength;
variables.velocities.material.uniforms.stiffness = uniforms.stiffness;
variables.velocities.material.uniforms.gravity = uniforms.gravity;
variables.velocities.material.uniforms.pinStrength =
uniforms.pinStrength;
variables.velocities.material.uniforms.textureTargetPositions = {
value: textures.targetPositions,
};
variables.positions.wrapS = variables.positions.wrapT = RepeatWrapping;
variables.velocities.wrapS = variables.velocities.wrapT =
RepeatWrapping;
const error = gpgpu.init();
if (error) {
reject(error);
} else {
resolve();
}
});
}
function generate() {
let points;
return Points.parse(size, data)
.then((geometry) => {
points = new Points(geometry, uniforms);
})
.then(() => Links.parse(points, data))
.then((geometry) => {
const links = new Links(geometry, uniforms);
scope.add(points, links);
points.renderOrder = links.renderOrder + 1;
scope.userData.hit.inherit(points);
})
.then(() => Labels.parse(size, data, scope.getLabelParseOptions()))
.then((result) => {
if (result) {
const labels = new Labels(result, uniforms);
scope.userData.labels = labels;
labels.renderOrder = points.renderOrder + 1;
scope.add(labels);
}
});
}
function complete() {
scope.ready = true;
if (callback) {
callback();
}
}
}
getLabelParseOptions() {
const { nodeAdjacency, nodeDegrees, labelFontFamily, uniforms, renderer } =
this.userData;
return {
adjacency: nodeAdjacency || [],
degrees: nodeDegrees || [],
fontFamily: labelFontFamily,
maxTextureSize: renderer?.capabilities?.maxTextureSize || 16384,
useMipmaps: renderer?.capabilities?.isWebGL2 === true,
};
}
refreshLabels() {
const { data, uniforms } = this.userData;
if (!data || !this.ready || !this.points) {
return Promise.resolve(null);
}
this.userData.labelRefreshToken =
(this.userData.labelRefreshToken || 0) + 1;
const refreshToken = this.userData.labelRefreshToken;
return Labels.parse(
uniforms.size.value,
data,
this.getLabelParseOptions(),
).then((result) => {
if (refreshToken !== this.userData.labelRefreshToken) {
if (result) {
result.texture?.dispose?.();
result.geometry?.dispose?.();
}
return this.userData.labels || null;
}
const previousLabels = this.userData.labels;
if (previousLabels) {
if (!result) {
this.remove(previousLabels);
previousLabels.dispose();
this.userData.labels = null;
return null;
}
previousLabels.replaceData(result);
if (this.userData.variables?.positions) {
previousLabels.material.uniforms.texturePositions.value =
this.getTexture('positions');
}
return previousLabels;
}
if (!result) {
this.userData.labels = null;
return null;
}
const nextLabels = new Labels(result, uniforms);
nextLabels.renderOrder = this.points.renderOrder + 1;
this.userData.labels = nextLabels;
this.add(nextLabels);
if (this.userData.variables?.positions) {
nextLabels.material.uniforms.texturePositions.value =
this.getTexture('positions');
}
return nextLabels;
});
}
/**
* @param {Number} time
* @description Function to update the instance meant to be run before three.js's renderer.render method.
* @returns {Void}
*/
update(time) {
if (!this.ready) {
return this;
}
const { gpgpu, renderer, textures, variables, uniforms } = this.userData;
uniforms.alpha.value *= uniforms.decay.value;
variables.velocities.material.uniforms.time.value = time / 1000;
gpgpu.compute();
renderer.getSize(size);
renderer.getDrawingBufferSize(drawingBufferSize);
uniforms.resolution.value.copy(drawingBufferSize);
uniforms.pixelRatio.value = size.x > 0 ? drawingBufferSize.x / size.x : 1;
const texture = this.getTexture('positions');
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
child.material.uniforms.texturePositions.value = texture;
if (child.material.uniforms.textureTargetPositions) {
child.material.uniforms.textureTargetPositions.value =
textures.targetPositions;
}
}
return this;
}
/**
* @param {THREE.Vector2} pointer - x, y values normalized to the camera's clipspace
* @param {THREE.Camera} camera - the camera to reference ray casting matrices
* @description Check to see if a point in the browser's screenspace intersects with any points in the force directed graph. If none found, then null is returned.
* @returns {Object|Null}
*/
intersect(pointer, camera) {
const { hit, renderer } = this.userData;
renderer.getSize(size);
hit.setSize(size.x, size.y);
hit.compute(renderer, camera);
const x = hit.ratio * size.x * clamp(pointer.x, 0, 1);
const y = hit.ratio * size.y * (1 - clamp(pointer.y, 0, 1));
renderer.readRenderTargetPixels(
hit.renderTarget,
x - 0.5,
y - 0.5,
1,
1,
buffers.int,
);
const [r, g, b, a] = buffers.int;
const z = 0;
const w = 255;
const isBlack = r === z && g === z && b === z && a === z;
const isWhite = r === w && g === w && b === w && a === w;
if (isBlack || isWhite) {
return null;
}
const index = rgbToIndex({ r, g, b }) - 1;
const point = this.getPositionFromIndex(index);
return {
point,
data: this.userData.data.nodes[index],
};
}
getTexture(name) {
const { gpgpu, variables } = this.userData;
return gpgpu.getCurrentRenderTarget(variables[name]).texture;
}
getPositionFromIndex(i) {
const { points, size } = this;
const { gpgpu, renderer, variables } = this.userData;
if (!points || !renderer || !size) {
console.warn(
'Force Directed Graph:',
'unable to calculate position without points or renderer.',
);
return;
}
const index = i * 3;
const uvs = points.geometry.attributes.position.array;
const uvx = Math.floor(uvs[index + 0] * size);
const uvy = Math.floor(uvs[index + 1] * size);
const renderTarget = gpgpu.getCurrentRenderTarget(variables.positions);
renderer.readRenderTargetPixels(
renderTarget,
uvx,
uvy,
1,
1,
buffers.float,
);
const [x, y, z] = buffers.float;
position.set(x, y, z);
return position;
}
setPointColorById(id, css) {
const index = this.getIndexById(id);
this.setPointColorFromIndex(index, css);
}
setPointColorFromIndex(index, css) {
const attribute = this.points.geometry.getAttribute('color');
const colors = attribute.array;
color.set(css);
colors[3 * index + 0] = color.r;
colors[3 * index + 1] = color.g;
colors[3 * index + 2] = color.b;
attribute.needsUpdate = true;
}
updateLinksColors() {
const { data } = this.userData;
const ref = this.points.geometry.attributes.color.array;
const sourceAttribute = this.links.geometry.getAttribute('sourceColor');
const targetAttribute = this.links.geometry.getAttribute('targetColor');
const sourceColors = sourceAttribute.array;
const targetColors = targetAttribute.array;
return each(data.links, (_, i) => {
const l = data.links[i];
const li = i * 3;
const si = 3 * l.sourceIndex;
const ti = 3 * l.targetIndex;
sourceColors[li + 0] = ref[si + 0];
sourceColors[li + 1] = ref[si + 1];
sourceColors[li + 2] = ref[si + 2];
targetColors[li + 0] = ref[ti + 0];
targetColors[li + 1] = ref[ti + 1];
targetColors[li + 2] = ref[ti + 2];
}).then(() => {
sourceAttribute.needsUpdate = true;
targetAttribute.needsUpdate = true;
return true;
});
}
getIndexById(id) {
const { registry } = this.userData;
return registry.get(id);
}
getLinksById(id) {
const { data } = this.userData;
const index = this.getIndexById(id);
const result = [];
const promise = each(data.links, (link) => {
const { sourceIndex, targetIndex } = link;
if (sourceIndex === index || targetIndex === index) {
result.push(link);
}
});
return promise.then(() => result);
}
getPointById(id) {
const { data } = this.userData;
const index = this.getIndexById(id);
return data.nodes[index];
}
dispose() {
const { gpgpu, workerManager } = this.userData;
if (gpgpu) {
for (let i = 0; i < gpgpu.variables.length; i++) {
const variable = gpgpu.variables[i];
variable.material.dispose();
variable.initialValueTexture.dispose();
for (let j = 0; j < variable.renderTargets.length; j++) {
const target = variable.renderTargets[j];
target.dispose();
}
}
}
if (workerManager) {
workerManager.dispose();
}
this.userData = {};
return this;
}
// Getters / Setters
get decay() {
return this.userData.uniforms.decay.value;
}
set decay(v) {
this.userData.uniforms.decay.value = v;
}
get alpha() {
return this.userData.uniforms.alpha.value;
}
set alpha(v) {
this.userData.uniforms.alpha.value = v;
}
get is2D() {
return this.userData.uniforms.is2D.value;
}
set is2D(v) {
this.userData.uniforms.is2D.value = v;
}
get time() {
return this.userData.uniforms.time.value;
}
set time(v) {
this.userData.uniforms.time.value = v;
}
get size() {
return this.userData.uniforms.size.value;
}
set size(v) {
this.userData.uniforms.size.value = v;
}
get maxSpeed() {
return this.userData.uniforms.maxSpeed.value;
}
set maxSpeed(v) {
this.userData.uniforms.maxSpeed.value = v;
}
get timeStep() {
return this.userData.uniforms.timeStep.value;
}
set timeStep(v) {
this.userData.uniforms.timeStep.value = v;
}
get damping() {
return this.userData.uniforms.damping.value;
}
set damping(v) {
this.userData.uniforms.damping.value = v;
}
get repulsion() {
return this.userData.uniforms.repulsion.value;
}
set repulsion(v) {
this.userData.uniforms.repulsion.value = v;
}
get springLength() {
return this.userData.uniforms.springLength.value;
}
set springLength(v) {
this.userData.uniforms.springLength.value = v;
}
get stiffness() {
return this.userData.uniforms.stiffness.value;
}
set stiffness(v) {
this.userData.uniforms.stiffness.value = v;
}
get gravity() {
return this.userData.uniforms.gravity.value;
}
set gravity(v) {
this.userData.uniforms.gravity.value = v;
}
get pinStrength() {
return this.userData.uniforms.pinStrength.value;
}
set pinStrength(v) {
this.userData.uniforms.pinStrength.value = v;
}
get nodeRadius() {
return this.userData.uniforms.nodeRadius.value;
}
set nodeRadius(v) {
this.userData.uniforms.nodeRadius.value = v;
}
get nodeScale() {
return this.userData.uniforms.nodeScale.value;
}
set nodeScale(v) {
this.userData.uniforms.nodeScale.value = v;
}
get sizeAttenuation() {
return this.userData.uniforms.sizeAttenuation.value;
}
set sizeAttenuation(v) {
this.userData.uniforms.sizeAttenuation.value = v;
}
get frustumSize() {
return this.userData.uniforms.frustumSize.value;
}
set frustumSize(v) {
this.userData.uniforms.frustumSize.value = v;
}
get linksInheritColor() {
return this.userData.uniforms.linksInheritColor.value;
}
set linksInheritColor(v) {
this.userData.uniforms.linksInheritColor.value = v;
}
get pointsInheritColor() {
return this.userData.uniforms.pointsInheritColor.value;
}
set pointsInheritColor(v) {
this.userData.uniforms.pointsInheritColor.value = v;
}
get labelsInheritColor() {
return this.userData.uniforms.labelsInheritColor.value;
}
set labelsInheritColor(v) {
this.userData.uniforms.labelsInheritColor.value = v;
}
get pointColor() {
return this.userData.uniforms.pointColor.value;
}
set pointColor(v) {
this.userData.uniforms.pointColor.value = v;
}
get linksColor() {
return this.linkColor;
}
set linksColor(v) {
this.linkColor = v;
}
get linkColor() {
return this.userData.uniforms.linkColor.value;
}
set linkColor(v) {
this.userData.uniforms.linkColor.value = v;
}
get labelsColor() {
return this.labelColor;
}
set labelsColor(v) {
this.labelColor = v;
}
get labelColor() {
return this.userData.uniforms.labelColor.value;
}
set labelColor(v) {
this.userData.uniforms.labelColor.value = v;
}
get linecap() {
const index = Math.round(this.userData.uniforms.linecap.value);
return LineCaps[index] || 'round';
}
set linecap(v) {
this.userData.uniforms.linecap.value = LineCapsMap[v] ?? LineCapsMap.round;
}
get linewidth() {
return this.userData.uniforms.linewidth.value;
}
set linewidth(v) {
this.userData.uniforms.linewidth.value = v;
}
get opacity() {
return this.userData.uniforms.opacity.value;
}
set opacity(v) {
this.userData.uniforms.opacity.value = v;
}
get beginning() {
return this.userData.uniforms.uBeginning.value;
}
set beginning(v) {
this.userData.uniforms.uBeginning.value = v;
}
get ending() {
return this.userData.uniforms.uEnding.value;
}
set ending(v) {
this.userData.uniforms.uEnding.value = v;
}
get obscurity() {
return this.userData.uniforms.obscurity.value;
}
set obscurity(v) {
this.userData.uniforms.obscurity.value = Math.max(0, Math.min(1, v));
}
get blending() {
return this.children[0].material.blending;
}
set blending(v) {
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
child.material.blending = v;
}
}
get points() {
return this.children[0];
}
get links() {
return this.children[1];
}
get labels() {
return this.userData.labels || null;
}
get uniforms() {
return this.userData.uniforms;
}
get nodeCount() {
const { variables } = this.userData;
return variables.velocities.material.uniforms.nodeAmount.value;
}
get edgeCount() {
const { variables } = this.userData;
return variables.velocities.material.uniforms.edgeAmount.value;
}
/**
* Get performance information about texture processing
* @returns {Object} Performance statistics
*/
getPerformanceInfo() {
const { workerManager } = this.userData;
return workerManager
? workerManager.getPerformanceInfo()
: {
workerSupported: false,
workerReady: false,
wasmReady: false,
pendingRequests: 0,
};
}
/**
* Check if worker-based processing is available
* @returns {boolean} True if worker processing is available
*/
isWorkerProcessingAvailable() {
const { workerManager } = this.userData;
return workerManager && workerManager.isReady();
}
/**
* Check if WASM acceleration is available
* @returns {boolean} True if WASM is available
*/
isWasmAccelerationAvailable() {
const { workerManager } = this.userData;
return workerManager && workerManager.isWasmAvailable();
}
}
export { ForceDirectedGraph };