things-graph-renderer/main.js

387 lines
12 KiB
JavaScript

import Graph from 'graphology';
import Sigma from 'sigma';
import forceAtlas2 from 'graphology-layout-forceatlas2';
import {bfsFromNode} from 'graphology-traversal/bfs';
import './style.css';
//const limitGraphRootNodeId = 'xyzrins:projects/studyforrest';
//const limitGraphRootNodeId = 'xyzrins:persons/michal-szczepanik';
//const limitGraphRootNodeId = 'xyzrins:projects/sfb1451';
const graphRootNodeId = (typeof limitGraphRootNodeId !== 'undefined' && limitGraphRootNodeId !== '') ? limitGraphRootNodeId : null;
const nodeStyles = {
instrument: {
color: '#f08324'
},
organization: {
color: '#2491f0'
},
person: {
color: '#24f083'
},
project: {
color: '#8324f0'
},
publication: {
color: '#432205'
},
topic: {
color: '#e924f0'
},
objective: {
color: '#f7b983'
},
dataset: {
color: '#33dbff'
}
};
// not used for now
const edgeStyles = {
http: {
color: '#999',
size: 1
},
sql: {
color: '#e31a1c',
size: 2
},
rpc: {
color: '#b15928',
size: 1.5
}
};
// Example color and size offsets
const HIGHLIGHT_NODE_COLOR = '#ffff00';
const HIGHLIGHT_EDGE_COLOR = '#ffcc00';
const NODE_SIZE_MULTIPLIER = 1.6;
const EDGE_SIZE_MULTIPLIER = 2;
async function loadGraphJson() {
const res = await fetch('/graph.json');
if (!res.ok) throw new Error('Failed to load graph.json');
return res.json();
}
/**
* Prune graph in-place to keep:
* - rootId and all nodes reachable from it (forward),
* - plus any nodes that have an immediate edge -> rootId.
*
* @param {Graph} graph
* @param {string} rootId
* @returns {void}
*/
function pruneToReachableOrImmediatePredecessors(graph, rootId) {
if (!graph.hasNode(rootId)) {
throw new Error(`Root node "${rootId}" not found in graph.`);
}
const keep = new Set();
// Forward reachable from root
// @mih disabled this, it adds noise from the perspective
// of page navigation needs. without it, all nodes with
// direct edges still remain in the filtered graph
//bfsFromNode(graph, rootId, (node) => keep.add(node));
// Immediate predecessors: nodes with an edge -> rootId
graph.forEachNeighbor(rootId, (neighbor) => keep.add(neighbor));
// Ensure root included
keep.add(rootId);
const toRemove = [];
graph.forEachNode((n) => {
if (!keep.has(n)) toRemove.push(n);
});
toRemove.forEach((n) => {
if (graph.hasNode(n)) graph.dropNode(n);
});
}
(async function init() {
const data = await loadGraphJson();
const graph = new Graph();
// Add nodes with temporary random positions for layout; include url if present
data.nodes.forEach((n) => {
const style = nodeStyles[n.type] || {
size: 10,
color: '#666'
};
const isRoot = (n.id === graphRootNodeId);
graph.addNode(n.id, {
label: n.label || n.id,
size: Math.log(n.size * 10) + 1,
color: style.color,
domainType: n.type,
url: n.url || null,
x: (Math.random() - 0.5),
y: (Math.random() - 0.5),
root: isRoot,
type: 'circle'
});
});
data.edges.forEach((e) => {
const style = edgeStyles[e.type] || {
color: '#71716f88',
size: 1
};
graph.addEdgeWithKey(e.id || `${e.source}->${e.target}`, e.source, e.target, {
label: e.type,
color: style.color,
size: style.size,
domainType: e.type
});
});
if (typeof graphRootNodeId !== 'undefined' && graphRootNodeId !== null && graphRootNodeId !== '') {
pruneToReachableOrImmediatePredecessors(graph, graphRootNodeId);
}
// Run ForceAtlas2 layout synchronously for a number of iterations
const iterations = 1000;
forceAtlas2.assign(
graph, {
iterations,
settings: {
barnesHutOptimize: true,
//linLogMode: true,
outboundAttractionDistribution: true,
//gravity: 100,
strongGravityMode: true,
//slowDown: 10,
adjustSizes: true
}
});
const container = document.getElementById('sigma-container');
const sigma = new Sigma(graph, container, {
renderLabels: true,
// Node reducer: returns visual attributes for rendering
nodeReducer: (node, data) => {
const highlighted = !!data.highlight;
const isRoot = !!data.root;
return {
...data,
size: (data.size || 1) * (highlighted ? NODE_SIZE_MULTIPLIER : 1)
};
},
// Edge reducer: returns visual attributes for rendering
edgeReducer: (edge, data) => {
const highlighted = !!data.highlight;
return {
...data,
color: highlighted ? HIGHLIGHT_EDGE_COLOR : data.color,
size: (data.size || 1) * (highlighted ? EDGE_SIZE_MULTIPLIER : 1),
hidden: data.hidden // preserve hidden state from your visibility logic
};
}
});
const camera = sigma.getCamera();
// Helper to update element display based on hidden attribute
function applyVisibility(graph, sigma) {
// Nodes: set a 'hidden' attribute already applied; Sigma will check it via renderers.
graph.forEachEdge((e, attr) => {
const sourceHidden = graph.getNodeAttribute(graph.source(e), 'hidden');
const targetHidden = graph.getNodeAttribute(graph.target(e), 'hidden');
graph.setEdgeAttribute(e, 'hidden', sourceHidden || targetHidden);
});
sigma.refresh();
}
// Build controls
const controlsList = document.getElementById('sigma-controls-list') || (() => {
const div = document.createElement('div');
div.id = 'sigma-controls-list';
document.getElementById('sigma-node-type-controls').appendChild(div);
return div;
})();
// Determine all domain types present in the graph and collect attributes
const domainTypeMap = new Map();
graph.forEachNode((n, attr) => {
const dt = (attr.domainType || 'unknown');
if (!domainTypeMap.has(dt)) {
domainTypeMap.set(dt, { key: dt, color: (nodeStyles[dt] && nodeStyles[dt].color) || '#666' });
}
});
// Convert to array, sort by capitalized label
const domainTypesArray = Array.from(domainTypeMap.values()).sort((a, b) => {
const la = a.key.toString().toLowerCase();
const lb = b.key.toString().toLowerCase();
if (la < lb) return -1;
if (la > lb) return 1;
return 0;
});
// Helper to capitalize label
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
// Create a checkbox for each type (sorted)
domainTypesArray.forEach((dtObj) => {
const dt = dtObj.key;
const id = `toggle-${dt}`;
const wrapper = document.createElement('div');
wrapper.style.marginBottom = '2px';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = id;
input.checked = true;
input.style.marginRight = '6px';
const styleColor = dtObj.color;
const swatch = document.createElement('span');
swatch.style.display = 'inline-block';
swatch.style.width = '12px';
swatch.style.height = '12px';
swatch.style.borderRadius = '50%';
swatch.style.backgroundColor = styleColor;
swatch.style.marginRight = '8px';
swatch.style.verticalAlign = 'middle';
const label = document.createElement('label');
label.htmlFor = id;
label.textContent = capitalize(dt);
label.style.verticalAlign = 'middle';
wrapper.appendChild(input);
wrapper.appendChild(swatch);
wrapper.appendChild(label);
controlsList.appendChild(wrapper);
input.addEventListener('change', () => {
const hidden = !input.checked;
graph.forEachNode((n, attr) => {
if ((attr.domainType || 'unknown') === dt) {
graph.setNodeAttribute(n, 'hidden', hidden);
}
});
applyVisibility(graph, sigma);
});
});
let selectedNode = null;
let lastTapTime = 0;
const DOUBLE_TAP_MS = 400;
const AUTO_CLEAR_MS = 4000;
let clearTimer = null;
function clearSelection() {
if (!selectedNode) return;
graph.forEachNode(n => graph.removeNodeAttribute(n, 'selected'));
graph.forEachEdge(e => graph.removeEdgeAttribute(e, 'selected'));
selectedNode = null;
sigma.refresh();
if (clearTimer) {
clearTimeout(clearTimer);
clearTimer = null;
}
}
function applySelection(node) {
// mark selected node and connected edges/nodes
selectedNode = node;
graph.forEachNode(n => graph.setNodeAttribute(n, 'selected', n === node));
graph.forEachEdge((e) => {
const src = graph.source(e),
tgt = graph.target(e);
const isConnected = src === node || tgt === node;
graph.setEdgeAttribute(e, 'selected', isConnected);
if (isConnected) {
graph.setNodeAttribute(src, 'selected', true);
graph.setNodeAttribute(tgt, 'selected', true);
}
});
sigma.refresh();
// auto-clear timer
if (clearTimer) clearTimeout(clearTimer);
clearTimer = setTimeout(clearSelection, AUTO_CLEAR_MS);
}
// Ensure initial visibility (no nodes hidden)
applyVisibility(graph, sigma);
// node tap handler
sigma.on('downNode', ({
node,
event
}) => {
const now = Date.now();
const url = graph.getNodeAttribute(node, 'url');
if (selectedNode === node) {
// second tap on selected node -> open URL if present
if (url) window.location.href = url;
else {
// already selected; maybe center again or show info
applySelection(node);
}
lastTapTime = 0;
return;
}
// different node tapped
// if quick double-tap detection is desired across nodes:
if (now - lastTapTime < DOUBLE_TAP_MS && selectedNode) {
// treat as confirmation tap for selectedNode
const selUrl = graph.getNodeAttribute(selectedNode, 'url');
if (selUrl) window.location.href = selUrl;
lastTapTime = 0;
return;
}
lastTapTime = now;
applySelection(node);
});
sigma.on('enterNode', ({
node
}) => {
// Highlight the hovered node
graph.forEachNode((n) =>
graph.setNodeAttribute(n, 'highlight', n === node)
);
// Highlight edges connected to the hovered node
// and also highlight the neighbor nodes (optional)
graph.forEachEdge((e, attr) => {
const src = graph.source(e);
const tgt = graph.target(e);
const isConnected = src === node || tgt === node;
graph.setEdgeAttribute(e, 'highlight', isConnected);
// optionally highlight the neighbor nodes as well:
if (isConnected) {
graph.setNodeAttribute(src, 'highlight', true);
graph.setNodeAttribute(tgt, 'highlight', true);
}
});
sigma.refresh();
});
sigma.on('leaveNode', () => {
// Clear highlight attributes on all nodes and edges
graph.forEachNode((n) => graph.removeNodeAttribute(n, 'highlight'));
graph.forEachEdge((e) => graph.removeEdgeAttribute(e, 'highlight'));
sigma.refresh();
});
// canvas tap to deselect
sigma.getContainer().addEventListener('pointerdown', (e) => {
// ignore if pointerdown was on a node (sigma emitted downNode first)
// small heuristic: if selectedNode and event.target is the container, clear
if (e.target === sigma.getContainer()) clearSelection();
});
// ensure graphRootNodeId exists and node is present (after pruning)
if (graphRootNodeId && graph.hasNode(graphRootNodeId)) {
// Sigma expects an object like the real event; minimal is { node }
sigma.emit('enterNode', { node: graphRootNodeId });
}
})();