stixviewer
Version:
Embeddable STIX2 graph viewer in JS
563 lines (488 loc) • 15 kB
JavaScript
import cytoscape from 'cytoscape';
import klay from 'cytoscape-klay';
import euler from 'cytoscape-euler';
import coseBilkent from 'cytoscape-cose-bilkent';
import cola from 'cytoscape-cola';
import dagre from 'cytoscape-dagre';
import cise from 'cytoscape-cise';
import cytoscapePopper from 'cytoscape-popper';
import autopanOnDrag from 'cytoscape-autopan-on-drag';
import {readFile} from './utils.js';
import {bundleToGraphElements} from './data.js';
import {renderSidebarContent} from './sidebar.js';
import {computePosition} from '@floating-ui/dom';
cytoscape.use(klay);
cytoscape.use(euler);
cytoscape.use(coseBilkent);
cytoscape.use(cola);
cytoscape.use(dagre);
cytoscape.use(cise);
// cytoscape-popper requires an explicit factory
// https://github.com/cytoscape/cytoscape.js-popper?tab=readme-ov-file#migration-from-v2
function popperFactory(ref, content, opts) {
function update() {
computePosition(ref, content, opts).then(({x, y}) => {
Object.assign(content.style, {
left: `${x}px`,
top: `${y}px`,
});
});
}
update();
return {update};
}
cytoscape.use(cytoscapePopper(popperFactory));
autopanOnDrag(cytoscape);
const DEFAULT_LAYOUT = 'cola';
const NODE_WIDTH = 30;
const NODE_HEIGHT = 30;
const LAYOUT_PROPS = {
'euler': {
pull: 0.006,
mass: (node) => 10,
animation: false,
dragCoeff: 0.3,
},
'cose-bilkent': {
animate: 'end',
animationEasing: 'ease-out',
animationDuration: 300,
nodeRepulsion: 200,
idealEdgeLength: NODE_WIDTH * 3,
gravityRange: 50,
gravity: 8.2,
// nestingFactor: 10,
padding: 50,
},
'cola': {
convergenceThreshold: 100, // end layout sooner, may be a bit lower quality
animate: false,
},
};
const DEFAULT_GRAPH_STYLE = [
{
selector: 'node',
style: {
'shape': 'data(shape)',
'width': NODE_WIDTH,
'height': NODE_HEIGHT,
'background-color': 'data(color)',
'background-width': '90%',
'background-height': '90%',
'background-position-x': '50%',
'text-valign': 'bottom',
'text-halign': 'center',
'label': '',
'font-size': '10pt',
'text-max-width': '300px',
'text-wrap': 'ellipsis',
},
},
{
selector: 'node[image]',
style: {
'background-image': 'data(image)',
},
},
{
selector: 'node[type="relationship"]',
style: {
'background-image': 'data(image)',
'width': NODE_WIDTH / 2,
'height': NODE_HEIGHT / 2,
'font-size': '8pt',
},
},
{
selector: 'node[type="marking-definition"]',
style: {
'width': NODE_WIDTH / 2,
'height': NODE_HEIGHT / 2,
'font-size': '8pt',
},
},
{
selector: 'node[type="extension-definition"]',
style: {
'width': NODE_WIDTH / 2,
'height': NODE_HEIGHT / 2,
'font-size': '8pt',
},
},
{
selector: 'node[type="language-content"]',
style: {
'width': NODE_WIDTH / 2,
'height': NODE_HEIGHT / 2,
'font-size': '8pt',
},
},
{
selector: 'node[type="idref"]',
style: {
'width': NODE_WIDTH / 2,
'height': NODE_HEIGHT / 2,
'font-size': '8pt',
},
},
{
selector: 'edge',
style: {
'width': 1,
'opacity': 0.5,
'label': 'data(label)',
'curve-style': 'bezier', // 'straight',
'line-color': '#bbb',
'target-arrow-color': '#ccc',
'target-arrow-shape': 'triangle',
'min-zoomed-font-size': '5pt',
},
},
{
selector: 'edge[label="x_eclecticiq_alternative_hypothesis_refs"]',
style: {
'curve-style': 'bezier',
'control-point-step-size': 40,
'line-color': '#ccc',
},
},
{
selector: '.bleak',
style: {
opacity: 0.1,
},
},
{
selector: 'edge.autorotate',
style: {
'font-size': '9pt',
'color': '#222',
'edge-text-rotation': 'autorotate',
},
},
{
selector: 'node:selected',
style: {
'background-color': 'black',
},
},
];
function addSidebarListener(cy) {
cy.nodes().on('click', (e) => {
e.preventDefault();
const node = e.target.data();
cy.sidebar.innerHTML = (
cy.sidebarRender ? cy.sidebarRender(node) : renderSidebarContent(node));
const closeIcon = cy.sidebar.querySelector('.sidebar-close-icon');
if (closeIcon) {
closeIcon.onclick = () => {
cy.sidebar.style.display = 'none';
};
}
cy.sidebar.style.display = 'block';
});
}
function downloadData(data) {
const hiddenElement = document.createElement('a');
hiddenElement.href = (
'data:application/json,' + encodeURIComponent(JSON.stringify(data, null, 4)));
hiddenElement.download = data['id'] + '.json';
hiddenElement.target = '_blank';
hiddenElement.click();
}
function downloadPng(data, img) {
const hiddenElement = document.createElement('a');
hiddenElement.href = img;
hiddenElement.download = 'graph-' + data['id'] + '.png';
hiddenElement.target = '_blank';
hiddenElement.click();
}
function initDownloadLinks(cy) {
const linkJsonDownload = cy.element.querySelector('.download-json');
if (linkJsonDownload) {
linkJsonDownload.onclick = function(e) {
e.preventDefault();
downloadData(cy.bundle);
};
}
const linkPngDownload = cy.element.querySelector('.download-png');
if (linkPngDownload) {
linkPngDownload.onclick = function(e) {
e.preventDefault();
downloadPng(cy.bundle, cy.png());
};
}
}
function clustersForCise(cy) {
// See docs for more details:
// https://github.com/iVis-at-Bilkent/cytoscape.js-cise/
const options = {};
const clusters = cy.elements().markovClustering(options);
for (let i = 0; i < clusters.length; i++) {
for (let j = 0; j < clusters[i].length; j++) {
clusters[i][j]._private.data.clusterID = i;
}
};
const arrayOfClusterArrays = [];
cy.nodes().forEach(function (node) {
arrayOfClusterArrays.push([node.data('id')]);
});
return arrayOfClusterArrays;
}
function runLayout(cy, layoutName) {
const localProps = {};
if (layoutName === 'cise') {
localProps.clusters = clustersForCise(cy);
}
const layout = cy.layout({
name: layoutName,
...LAYOUT_PROPS[layoutName],
...localProps,
});
layout.run();
setTimeout(function() {
layout.stop();
}, 300);
cy.layoutName = layoutName;
}
function initWrapper(element, options) {
const {caption, width, height, showFooter} = options;
element.classList.add('stix-viewer-block');
const stixViewer = document.createElement('div');
stixViewer.classList.add('stix-viewer');
element.appendChild(stixViewer);
const stixGraph = document.createElement('div');
stixGraph.classList.add('stix-graph');
stixViewer.appendChild(stixGraph);
element.style.width = (isNaN(width) && width) || (width + 'px');
stixGraph.style.width = '100%';
stixGraph.style.height = (isNaN(height) && height) || (height + 'px');
if (caption) {
const captionDiv = document.createElement('div');
captionDiv.setAttribute('class', 'viewer-header');
captionDiv.innerText = caption;
element.insertBefore(captionDiv, element.firstChild);
}
if (showFooter) {
const viewerFooter = document.createElement('div');
viewerFooter.setAttribute('class', 'viewer-footer');
viewerFooter.innerHTML = `
made with <a href="https://traut.github.io/stixview/">Stixview</a>
<span style="float:right">
<a href="#" class="download-json">STIX2</a>
<a href="#" class="download-png">PNG</a>
</span>
`;
element.appendChild(viewerFooter);
}
return element;
}
function initDragDrop(elem, callback) {
elem.addEventListener('dragover', (e) => {
e.stopPropagation();
e.preventDefault();
elem.classList.add('dragover-active');
e.dataTransfer.dropEffect = 'copy';
});
elem.addEventListener('dragleave', (e) => {
e.stopPropagation();
e.preventDefault();
elem.classList.remove('dragover-active');
});
elem.addEventListener('drop', (e) => {
e.stopPropagation();
e.preventDefault();
elem.classList.remove('dragover-active');
const files = e.dataTransfer.files; // Array of all files
if (files.length > 1) {
console.error('More than 1 file dropped, picking the first one', files);
};
if (files.lengh == 0) {
return;
}
const file = files[0];
readFile(file, callback);
});
}
function initGraph(element, viewProps, dataFetchCallback) {
const {
// default view settings
layout,
caption,
showFooter,
showSidebar,
showLabels,
allowDragDrop,
enableMouseZoom,
enablePanning,
graphWidth,
graphHeight,
minZoom,
maxZoom,
// extra settings
graphStyle,
onClickNode,
sidebarRender,
} = viewProps;
const width = graphWidth || element.clientWidth || 800;
const height = graphHeight || 600;
element = initWrapper(element, {width, height, caption, showFooter});
const viewer = element.querySelector('.stix-viewer');
if (allowDragDrop) {
viewer.querySelector('.stix-graph').innerHTML = (
`<div class='viewer-placeholder'>Drag and drop STIX2 json file here</div>`);
initDragDrop(element, (bundle) => dataFetchCallback(bundle));
}
const cy = cytoscape({
style: graphStyle || DEFAULT_GRAPH_STYLE,
userZoomingEnabled: enableMouseZoom,
userPanningEnabled: enablePanning,
});
cy.stixviewContainer = viewer.querySelector('.stix-graph');
cy.minZoom(minZoom || 0.3);
cy.maxZoom(maxZoom || 2.5);
if (showSidebar) {
const sidebar = document.createElement('div');
sidebar.setAttribute('class', 'sidebar');
viewer.appendChild(sidebar);
cy.sidebar = sidebar;
}
if (onClickNode) {
cy.on('click', 'node', (e) => {
e.preventDefault();
const clickedNode = e.target.data();
onClickNode(clickedNode);
});
}
cy.layoutName = layout;
cy.stixId = element.dataset.stixViewId;
cy.element = element;
cy.sidebarRender = sidebarRender;
const graph = {
cy: cy,
element: element,
viewProps: {
showLabels: showLabels,
},
runLayout: (layoutName) => runLayout(cy, layoutName),
toggleLabels: (showLabels) => {
cy.style()
.selector('node')
.style('label', showLabels ? 'data(label)' : '')
.update();
},
fit: () => cy.fit(),
toggleLoading: (isLoading) => {
if (isLoading) {
element.classList.add('loading');
removePlaceholder(element);
} else {
element.classList.remove('loading');
}
},
setSidebarRender: () => {
cy.sidebarRender = sidebarRender;
},
};
cy.resize();
return graph;
}
function removePlaceholder(element) {
const placeholder = element.querySelector('.viewer-placeholder');
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.removeChild(placeholder);
}
}
function attachTag(cy, node, tag, placement) {
const div = document.createElement('div');
div.innerHTML = tag.value.toUpperCase();
div.classList.add('marking-tag');
div.classList.add(tag.css);
placement = placement || 'right-start';
const popper = node.popper({
content: () => {
cy.element.appendChild(div);
return div;
},
popper: {
placement: placement,
},
});
const update = () => {
popper.update();
const dim = Math.min(Math.max(cy.zoom() * 8, 2), 10);
div.style.fontSize = dim + 'pt';
div.style.lineHeight = Math.ceil(dim) + 'pt';
};
node.on('position', update);
cy.on('pan zoom resize', update);
return {
element: div,
removeListeners: () => {
cy.off('pan zoom resize', update);
},
};
}
function loadGraph(graph, bundle, dataProps, callback) {
const {
highlightedObjects,
hiddenObjects,
showTlpAsTags,
showMarkingNodes,
showIdrefs,
} = dataProps;
const cy = graph.cy;
cy.remove('node');
cy.remove('edge');
cy.mount(cy.stixviewContainer);
removePlaceholder(graph.element);
const graphElements = bundleToGraphElements(
bundle,
{
highlightedObjects,
hiddenObjects,
showTlpAsTags,
showMarkingNodes,
showIdrefs,
}
);
cy.add(graphElements);
cy.bundle = bundle;
cy.once('layoutstop', () => callback && callback(graph));
if (cy.tags) {
// clean up all tag elements
cy.tags.forEach((tag) => {
tag.removeListeners();
const el = tag.element;
el.parentNode && el.parentNode.removeChild(el);
});
cy.tags = [];
}
if (showTlpAsTags) {
cy.tags = [];
cy.nodes().forEach((node) => {
const data = node.data();
if (!data.tags || !data.tags.length) {
return;
}
const placements = ['right-start', 'right', 'right-end'];
// at the moment only 3 tags are supported
const tags = data.tags.slice(0, 3);
tags.forEach((tag, index) => {
const placement = placements[index];
const obj = attachTag(cy, node, tag, placement);
cy.tags.push(obj);
});
});
}
graph.toggleLabels(graph.viewProps.showLabels);
if (!graphElements) {
callback && callback(graph);
}
initDownloadLinks(cy);
runLayout(cy, cy.layoutName || DEFAULT_LAYOUT);
if (cy.sidebar) {
addSidebarListener(cy);
}
}
export {initGraph, loadGraph};