387 lines
12 KiB
JavaScript
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 });
|
|
}
|
|
})();
|
|
|