entity_mesh-1.1.1/js/entity-mesh.js
js/entity-mesh.js
/**
* @file
* Provides JavaScript functionality for the Entity Mesh visualization.
*/
/* global d3, entityMeshData */
(function (Drupal, once) {
/**
* Attaches behavior to initialize the Entity Mesh graph.
*/
Drupal.behaviors.entityMesh = {
attach(context, settings) {
once('entityMesh', '#entity-mesh-graph', context).forEach((element) => {
element.classList.add('processed');
// Ensure data is available before generating the graph.
if (settings.entity_mesh?.data) {
this.graphGenerate(
settings.entity_mesh.data,
settings.entity_mesh.settings,
);
} else if (typeof entityMeshData !== 'undefined') {
this.graphGenerate(entityMeshData, settings.entity_mesh.settings);
}
});
},
/**
* Generates the D3 graph visualization.
*
* @param {Object} data - The graph data including nodes, links, and types.
* @param {Object} settings - Graph settings.
*/
graphGenerate(data, settings) {
// Initialize objects to ensure they are reset for each request.
const selector = '#entity-mesh-graph';
const nodes = data.nodes || [];
const links = data.links || [];
const types = data.types || [];
let selectedNode = null;
let filterTypes = [];
let legendItems;
// Calculate the total number of nodes for each type.
const typesTotal = Object.values(
nodes.reduce((acc, item) => {
if (!acc[item.type]) {
acc[item.type] = { name: item.type, total: 0 };
}
acc[item.type].total += 1;
return acc;
}, {}),
).sort((a, b) => a.name.localeCompare(b.name));
// Initialize graph dimensions.
const graph = document.querySelector(selector);
const graphCStyle = window.getComputedStyle(graph);
const width =
graph.clientWidth +
parseFloat(graphCStyle.paddingLeft) +
parseFloat(graphCStyle.paddingRight);
const height = settings.fullHeight ? window.innerHeight - 300 : 450;
// Ensure D3 is available
if (typeof d3 === 'undefined') {
console.error('D3.js is not loaded.');
return;
}
// Initialize D3 color scale.
const color = d3.scaleOrdinal(types, d3.schemeCategory10);
// Generate D3 SVG graph.
const svg = d3
.select(selector)
.append('svg')
.attr('width', '100%')
.attr('height', height);
// Initialize D3 force simulation.
const simulation = d3
.forceSimulation()
.force('center', d3.forceCenter(width / 2, height / 2))
.force(
'link',
d3.forceLink().id((d) => d.id),
)
.force(
'charge',
d3
.forceManyBody()
.strength(-200)
.distanceMax(Math.min(width, height) / 2),
)
.force('collide', d3.forceCollide().strength(-200))
.force('y', d3.forceY(0))
.force('x', d3.forceX(0));
// Create a wrapper group for zoom functionality.
const wrapper = svg.append('g').attr('class', 'zoom-wrapper');
// Define arrow markers for directed links.
wrapper
.append('defs')
.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 -5 10 10')
.attr('fill', '#DDD')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('class', 'arrow-head');
// Draw links.
const link = wrapper
.append('g')
.attr('class', 'lines')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke', '#DDD')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow)');
// Draw nodes.
const node = wrapper
.append('g')
.attr('class', 'nodes')
.selectAll('g')
.data(nodes)
.enter()
.append('g')
.attr('class', 'node');
/**
* Gets the count of links for a node.
*
* @param {string} nodeId - The node ID.
* @returns {number} - The count of links.
*/
const getLinkCount = (nodeId) => {
const count = links.filter((link) => link.target === nodeId).length;
return count > 0 ? count : '';
};
/**
* Calculates node radius.
*
* @param {string} node - The node ID.
* @returns {number} - The node radius
*/
const getNodeRadius = (node) => {
const baseRadius = 5;
const variableSize =
getLinkCount(node.id) * (Math.log10(getLinkCount(node.id)) + 1);
return Math.min(
baseRadius + (Number.isNaN(variableSize) ? 1 : variableSize),
50,
);
};
const labels = node
.append('text')
.attr('class', 'node-label')
.attr('text-anchor', 'left')
.attr('dx', (d) => getNodeRadius(d) + 3)
.attr('dy', 3)
.text((d) => d.label)
.style('display', 'none')
.style('cursor', 'pointer')
.style('pointer-events', 'all')
.on('click', (event, d) => {
event.stopPropagation();
if (d.url) {
window.open(d.url, '_blank', 'noopener');
}
});
// Add circles to nodes.
node
.append('circle')
.attr('r', (d) => {
return getNodeRadius(d);
})
.attr('fill', (d) => color(d.type));
/**
* Handles drag start event.
*/
const dragStarted = (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
};
/**
* Handles dragging event.
*/
const dragged = (event, d) => {
d.fx = event.x;
d.fy = event.y;
};
/**
* Handles drag end event.
*/
const dragEnded = (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
};
// Enable dragging.
const dragHandler = d3
.drag()
.on('start', dragStarted)
.on('drag', dragged)
.on('end', dragEnded);
dragHandler(node);
// Add tooltips.
node.append('title').text((d) => d.label);
// Show link counts.
node
.append('text')
.text((d) => (getLinkCount(d.id) > 1 ? getLinkCount(d.id) : ''))
.attr('font-size', '10px')
.attr('fill', 'white')
.attr('text-anchor', 'middle')
.attr('y', 3);
/**
* Handles zoom actions.
*/
const zoomActions = (event) => {
wrapper.attr('transform', event.transform);
};
// Apply zoom behavior.
const zoomHandler = d3.zoom().on('zoom', zoomActions);
zoomHandler(svg);
/**
* Updates node positions at each tick of the simulation.
*/
const ticked = () => {
node.attr('transform', (d) => `translate(${d.x},${d.y})`);
link
.attr('x1', (d) => d.source.x)
.attr('y1', (d) => d.source.y)
.attr('x2', (d) => d.target.x)
.attr('y2', (d) => d.target.y);
};
// Apply forces.
simulation.nodes(nodes).alphaTarget(0).on('tick', ticked);
simulation.force('link').links(links);
/**
* Gets connected nodes for a given node ID.
*
* @param {string} nodeId - The ID of the node.
* @param {number} [depth=0] - The depth of connection to traverse.
* @returns {Set<string>} - A set of connected node IDs.
*/
const getConnectedNodes = (nodeId, depth = 0) => {
const connectedNodes = new Set();
const visitedNodes = new Set();
const traverse = (currentNodeId, depth) => {
visitedNodes.add(currentNodeId);
links.forEach((link) => {
// New node target (first level only):
if (
link.source.id === currentNodeId &&
!visitedNodes.has(link.target.id)
) {
connectedNodes.add(link.target.id);
if (depth > 0) {
traverse(link.target.id, depth - 1);
}
}
// New node that points to the target node (first level only)
else if (
link.target.id === currentNodeId &&
!visitedNodes.has(link.source.id)
) {
connectedNodes.add(link.source.id);
if (depth > 0) {
traverse(link.source.id, depth - 1);
}
}
});
};
traverse(nodeId, depth);
return connectedNodes;
};
/**
* Filters nodes and links based on selected node and types.
*/
const filterNodes = () => {
// Hide All:
node.attr('display', 'none');
link.attr('display', 'none');
labels.style('display', 'none');
let nodesVisible = node;
let linksVisible = link;
// Filter by Selected node:
if (selectedNode !== null) {
const connectedNodes = getConnectedNodes(selectedNode.id, 0);
// Remove all nodes not connected but current one:
nodesVisible = node.filter(
(d) => connectedNodes.has(d.id) || d.id === selectedNode.id,
);
// Remove all links from nodes not in the list but current one:
linksVisible = link.filter(
(d) =>
d.source.id === selectedNode.id ||
d.target.id === selectedNode.id ||
(connectedNodes.has(d.source.id) &&
connectedNodes.has(d.target.id)),
);
labels
.filter((d) => connectedNodes.has(d.id) || d.id === selectedNode.id)
.style('display', 'inline');
}
// Filter by Type:
if (filterTypes.length > 0) {
nodesVisible = nodesVisible.filter(
(d) => filterTypes.indexOf(d.type) !== -1,
);
linksVisible = linksVisible.filter(
(d) =>
filterTypes.indexOf(d.source.type) !== -1 &&
filterTypes.indexOf(d.target.type) !== -1,
);
}
// Show Visible ones:
nodesVisible.attr('display', 'inline');
linksVisible.attr('display', 'inline');
};
/**
* Toggles visibility of node types in the legend.
*/
const toggleLegends = (event, clickedLegend) => {
const index = filterTypes.indexOf(clickedLegend.name);
if (index === -1) {
filterTypes.push(clickedLegend.name);
d3.select(event.currentTarget).classed('selected', true);
} else {
filterTypes.splice(index, 1);
d3.select(event.currentTarget).classed('selected', false);
}
if (filterTypes.length === types.length) {
filterTypes = [];
// @todo remove
legendItems.classed('selected', false);
}
filterNodes();
};
// Legend
const legend = svg
.append('g')
.attr('class', 'legend')
.attr('transform', 'translate(20, 20)');
legendItems = legend
.selectAll('.legend-item')
.data(typesTotal)
.enter()
.append('g')
.attr('x', 20)
.attr('y', 10)
.attr('class', 'legend-item')
.attr('transform', (d, i) => `translate(0, ${i * 30})`);
legendItems
.append('rect')
.attr('width', 10)
.attr('height', 10)
.style('fill', (d) => color(d.name));
legendItems
.append('text')
.attr('x', 20)
.attr('y', 10)
.text((d) => `${d.name} (${d.total})`)
.on('click', toggleLegends);
const toggleConnections = (event, clickedNode) => {
node.classed('selected', false);
// Revert selection:
if (clickedNode === selectedNode) {
node.attr('display', 'inline');
link.attr('display', 'inline');
labels.style('display', 'none');
selectedNode = null;
return;
}
selectedNode = clickedNode;
// Apply the 'selected' class to the clicked node
d3.select(event.currentTarget).classed('selected', true);
filterNodes();
};
node.on('click', toggleConnections);
},
};
})(Drupal, once);
