aboutsummaryrefslogtreecommitdiffstats
path: root/ui/imports/ui/components/network-graph/network-graph.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui/imports/ui/components/network-graph/network-graph.js')
-rw-r--r--ui/imports/ui/components/network-graph/network-graph.js697
1 files changed, 697 insertions, 0 deletions
diff --git a/ui/imports/ui/components/network-graph/network-graph.js b/ui/imports/ui/components/network-graph/network-graph.js
new file mode 100644
index 0000000..49e41a8
--- /dev/null
+++ b/ui/imports/ui/components/network-graph/network-graph.js
@@ -0,0 +1,697 @@
+/////////////////////////////////////////////////////////////////////////////////////////
+// Copyright (c) 2017 Koren Lev (Cisco Systems), Yaron Yogev (Cisco Systems) and others /
+// /
+// All rights reserved. This program and the accompanying materials /
+// are made available under the terms of the Apache License, Version 2.0 /
+// which accompanies this distribution, and is available at /
+// http://www.apache.org/licenses/LICENSE-2.0 /
+/////////////////////////////////////////////////////////////////////////////////////////
+/*
+ * Template Component: NetworkGraph
+ */
+
+//import { Meteor } from 'meteor/meteor';
+import { Template } from 'meteor/templating';
+import { ReactiveDict } from 'meteor/reactive-dict';
+import { SimpleSchema } from 'meteor/aldeed:simple-schema';
+import * as R from 'ramda';
+import * as cola from 'webcola';
+import { imagesForNodeType, defaultNodeTypeImage } from '/imports/lib/images-for-node-type';
+
+import './network-graph.html';
+
+/*
+ * Lifecycles
+ */
+
+Template.NetworkGraph.onCreated(function() {
+ let instance = this;
+
+ instance.state = new ReactiveDict();
+ instance.state.setDefault({
+ graphDataChanged: null,
+ });
+ instance.simpleState = {
+ graphData: null
+ };
+
+ instance.autorun(function () {
+ let data = Template.currentData();
+
+ new SimpleSchema({
+ graphData: { type: Object, blackbox: true },
+ onNodeOver: { type: Function, optional: true },
+ onNodeOut: { type: Function, optional: true },
+ onNodeClick: { type: Function, optional: true },
+ onDragStart: { type: Function, optional: true },
+ onDragEnd: { type: Function, optional: true },
+ }).validate(data);
+
+ instance.simpleState.graphData = data.graphData;
+ instance.state.set('graphDataChanged', Date.now());
+ instance.onNodeOver = R.defaultTo(() => {}, data.onNodeOver);
+ instance.onNodeOut = R.defaultTo(() => {}, data.onNodeOut);
+ instance.onNodeClick = R.defaultTo(() => {}, data.onNodeClick);
+ instance.onDragStart = R.defaultTo(() => {}, data.onDragStart);
+ instance.onDragEnd = R.defaultTo(() => {}, data.onDragEnd);
+ });
+});
+
+Template.NetworkGraph.rendered = function() {
+ let instance = Template.instance();
+
+ instance.autorun(function () {
+ //let _graphDataChanged =
+ instance.state.get('graphDataChanged');
+ let graphEl = instance.$('.sm-graph')[0];
+
+ renderGraph(graphEl,
+ graphEl.clientWidth,
+ graphEl.clientHeight,
+ instance.simpleState.graphData,
+ genConfig(),
+ instance.onNodeOver,
+ instance.onNodeOut,
+ instance.onNodeClick,
+ instance.onDragStart,
+ instance.onDragEnd
+ );
+ });
+};
+
+/*
+ * Events
+ */
+
+Template.NetworkGraph.events({
+});
+
+/*
+ * Helpers
+ */
+
+Template.NetworkGraph.helpers({
+}); // end: helpers
+
+
+function genConfig() {
+ let outline = false;
+ let tocolor = 'fill';
+ let towhite = 'stroke';
+ if (outline) {
+ tocolor = 'stroke';
+ towhite = 'fill';
+ }
+
+ return {
+ initialLinkLabelsFontSize: 18,
+ tocolor: tocolor,
+ towhite: towhite,
+ text_center: false,
+ outline: outline,
+ min_score: 0,
+ max_score: 1,
+ highlight_color: 'blue',
+ highlight_trans: 0.1,
+ default_node_color: '#ccc',
+ //var default_node_color: 'rgb(3,190,100)',
+ default_link_color: '#888',
+ nominal_base_node_size: 8,
+ nominal_text_size: 10,
+ max_text_size: 24,
+ nominal_stroke: 1.5,
+ max_stroke: 4.5,
+ max_base_node_size: 36,
+ min_zoom: 0.3,
+ max_zoom: 5,
+ };
+}
+
+function renderGraph(
+ mainElement,
+ w,
+ h,
+ graph,
+ config,
+ onNodeOver,
+ onNodeOut,
+ onNodeClick,
+ onDragStart,
+ onDragEnd
+) {
+
+ let force = genForceCola(cola, d3, w, h);
+ let drag = force.drag()
+ .on('start', function (_d) {
+ onDragStart();
+ })
+ .on('end', function (_d) {
+ onDragEnd();
+ })
+ ;
+
+ let svg = d3.select(mainElement).select('svg');
+ svg.remove();
+ svg = genSvg(d3, mainElement);
+
+ let zoom = genZoomBehavior(d3, config);
+ svg.call(zoom);
+
+ let mainEl = svg.append('g');
+ let groupsEl = mainEl.append('g').attr('class', 'groups-container');
+ let linksEl = mainEl.append('g').attr('class', 'links-container');
+ let nodesEl = mainEl.append('g').attr('class', 'nodes-container');
+
+ renderView(force, {
+ graph: graph,
+ viewGraph: {
+ nodes: [],
+ links: [],
+ groups: []
+ },
+ },
+ mainEl,
+ groupsEl,
+ nodesEl,
+ linksEl,
+ drag, zoom, config,
+ onNodeOver, onNodeOut, onNodeClick);
+}
+
+ // d3.select(window).on('resize', resize);
+
+function genSvg(d3, mainElement) {
+ let svg = d3.select(mainElement).append('svg');
+
+ svg.style('cursor', 'move')
+ .attr('width', '100%')
+ .attr('height', '100%')
+ .attr('pointer-events', 'all');
+
+ return svg;
+}
+
+function genSvgLinks(g, links, nominal_stroke, default_link_color, initialLinkLabelsFontSize) {
+ let svgLinks = g.selectAll('.link-group')
+ .data(links, (d) => d._osid);
+
+ let svgLinksEnter = svgLinks
+ .enter()
+ .append('g')
+ .attr('class', 'link-group')
+ .attr('data-link-id', function (d) {
+ return d._osid;
+ })
+ ;
+
+ //let svgLinksExit =
+ svgLinks
+ .exit().remove();
+
+ let svgLinkLines = svgLinksEnter
+ .append('line')
+ .attr('class', 'link-line')
+ .style('stroke-width', nominal_stroke)
+ .style('stroke',
+ function(_d) {
+ return default_link_color;
+ });
+
+ let svgLinkLabels = svgLinksEnter
+ .append('text')
+ .text(function(d) {
+ return d.label;
+ })
+ .attr('class', 'link-label')
+ .attr('x', function(d) { return (d.source.x + (d.target.x - d.source.x) * 0.5); })
+ .attr('y', function(d) { return (d.source.y + (d.target.y - d.source.y) * 0.5); })
+ .attr('dy', '.25em')
+ .attr('text-anchor', 'right')
+ .attr('font-size', initialLinkLabelsFontSize)
+ ;
+
+ return {svgLinks, svgLinkLines, svgLinkLabels};
+}
+
+function genSvgNodes(g, nodes, drag, onNodeOver, onNodeOut, onNodeClick, onGroupNodeClick) {
+ let svgNodes = g.selectAll('.node')
+ .data(nodes, (d) => d._osid);
+
+ let svgNodesEnter = svgNodes
+ .enter()
+ .append('g')
+ .attr('class', 'node')
+ .attr('data-node-id', (d) => d._osid)
+ .call(drag);
+
+ //let svgNodesExit =
+ svgNodes
+ .exit().remove();
+
+ let imageLength = 36;
+ let svgImages = svgNodesEnter.append('image')
+ .attr('class', 'node-image')
+ .attr('xlink:href', function(d) {
+ return `/${calcImageForNodeType(d._osmeta.type)}`;
+ })
+ .attr('x', -(Math.floor(imageLength / 2)))
+ .attr('y', -(Math.floor(imageLength / 2)))
+ .attr('width', imageLength)
+ .attr('height', imageLength)
+ .on('mouseover', function (d) {
+ onNodeOver(d._osmeta.nodeId, d3.event.pageX, d3.event.pageY);
+ })
+ .on('mouseout', function (d) {
+ onNodeOut(d._osmeta.nodeId);
+ })
+ .on('click', function (d) {
+ if (R.path(['_osmeta', 'type'], d) === 'view_group') {
+ onGroupNodeClick(d._osmeta.nodeId);
+ }
+ onNodeClick(d._osmeta.nodeId);
+ })
+ ;
+
+ return {svgNodes, svgImages};
+ //return [svgNodes];
+}
+
+function calcImageForNodeType(nodeType) {
+ return R.defaultTo(defaultNodeTypeImage, R.prop(nodeType, imagesForNodeType));
+}
+
+function genZoomBehavior(d3, config) {
+ let zoom = d3.zoom().scaleExtent([config.min_zoom, config.max_zoom]);
+
+ return zoom;
+}
+
+/*
+function genForceD3(d3, w, h) {
+ let force = d3.layout.force()
+ .linkDistance(60)
+ .charge(-300)
+ .size([w,h]);
+
+ return force;
+}
+*/
+
+function genForceCola(cola, d3, w, h) {
+ let force = cola.d3adaptor(d3)
+ .convergenceThreshold(0.1)
+ // .convergenceThreshold(1e-9)
+ .linkDistance(120)
+ .size([w,h]);
+
+ return force;
+}
+
+function activateForce(force, nodes, links, groups) {
+ force
+ .nodes(nodes)
+ .links(links)
+ .groups(groups)
+ //.symmetricDiffLinkLengths(25)
+ .handleDisconnected(true)
+ .avoidOverlaps(true)
+ .start(50, 100, 200);
+ //.start();
+}
+
+/*
+function resize() {
+ let width = mainElement.clientWidth;
+ let height = mainElement.clientHeight;
+
+ svg.attr('width', '100%') //width)
+ .attr('height', '100%'); //height);
+
+ force.size([
+ force.size()[0] + (width - w) / zoom.scale(),
+ force.size()[1] + (height - h) / zoom.scale()
+ ]).resume();
+
+ w = width;
+ h = height;
+}
+*/
+
+function renderView(force,
+ state,
+ mainEl,
+ groupsEl,
+ nodesEl,
+ linksEl,
+ drag,
+ zoom,
+ config,
+ onNodeOver,
+ onNodeOut,
+ onNodeClick) {
+
+ state.viewGraph = calcViewGraph(state.graph, state.viewGraph);
+
+ activateForce(force, state.viewGraph.nodes, state.viewGraph.links, state.viewGraph.groups);
+
+ zoom.on('zoom', zoomFn);
+
+ genSvgGroups(groupsEl, state.viewGraph.groups, drag, onRenderViewReq);
+
+ genSvgLinks(
+ linksEl, state.viewGraph.links,
+ config.nominal_stroke,
+ config.default_link_color,
+ config.initialLinkLabelsFontSize
+ );
+
+ genSvgNodes(
+ nodesEl, state.viewGraph.nodes, drag, onNodeOver, onNodeOut, onNodeClick,
+ function onGroupNodeClick(groupId) {
+ let group = R.find(R.propEq('_osid', groupId), state.graph.groups);
+ group.isExpanded = true;
+
+ state.viewGraph = renderView(force, state,
+ mainEl, groupsEl, nodesEl, linksEl,
+ drag, zoom, config,
+ onNodeOver, onNodeOut, onNodeClick);
+ });
+
+ force.on('tick', tickFn);
+
+ function onRenderViewReq() {
+ state.viewGraph = renderView(force, state,
+ mainEl, groupsEl, nodesEl, linksEl,
+ drag, zoom, config,
+ onNodeOver, onNodeOut, onNodeClick);
+ }
+
+ function tickFn() {
+ let svgGroups = mainEl.selectAll('.group');
+ svgGroups
+ .attr('x', function (d) {
+ return R.path(['bounds', 'x'], d);
+ })
+ .attr('y', function (d) {
+ return R.path(['bounds', 'y'], d);
+ })
+ .attr('width', function (d) {
+ if (d.bounds) { return d.bounds.width(); }
+ })
+ .attr('height', function (d) {
+ if (d.bounds) { return d.bounds.height(); }
+ });
+
+ let svgNodes = mainEl.selectAll('.node');
+ svgNodes.attr('transform', function(d) {
+ return 'translate(' + d.x + ',' + d.y + ')';
+ });
+
+ let svgLinkLines = mainEl.selectAll('.link-group').selectAll('.link-line');
+ svgLinkLines
+ .attr('x1', function(d) {
+ return d.source.x;
+ })
+ .attr('y1', function(d) { return d.source.y; })
+ .attr('x2', function(d) { return d.target.x; })
+ .attr('y2', function(d) { return d.target.y; });
+
+ let svgLinkLabels = mainEl.selectAll('.link-group').selectAll('.link-label');
+ svgLinkLabels
+ .attr('x', function(d) {
+ return (d.source.x + (d.target.x - d.source.x) * 0.5);
+ })
+ .attr('y', function(d) {
+ return (d.source.y + (d.target.y - d.source.y) * 0.5);
+ });
+
+ }
+
+ function zoomFn() {
+ mainEl.attr('transform', d3.event.transform);
+
+ let trn = d3.event.transform;
+
+ let maxZoomAllowedForNodes = 1.8;
+ let imageInitialLength = 36;
+ let imageLength;
+
+ if (trn.k > maxZoomAllowedForNodes) {
+ imageLength = (imageInitialLength / trn.k) * maxZoomAllowedForNodes;
+ } else {
+ imageLength = imageInitialLength;
+ }
+
+ let svgImages = mainEl.selectAll('.node-image');
+ svgImages
+ .attr('x', -(Math.floor(imageLength / 2)))
+ .attr('y', -(Math.floor(imageLength / 2)))
+ .attr('width', imageLength)
+ .attr('height', imageLength)
+ ;
+
+ let labelsFontSize;
+
+ if (trn.k > maxZoomAllowedForNodes) {
+ labelsFontSize = (config.initialLinkLabelsFontSize / trn.k) * maxZoomAllowedForNodes;
+ } else {
+ labelsFontSize = config.initialLinkLabelsFontSize;
+ }
+
+ let svgLinkLabels = mainEl.selectAll('.link-group').selectAll('.link-label');
+ svgLinkLabels
+ .attr('font-size', labelsFontSize);
+ }
+
+ return state.viewGraph;
+}
+
+function genSvgGroups(g, groups, drag, onRenderViewReq) {
+ let svgGroups = g.selectAll('.group')
+ .data(groups, (d) => d._osid);
+
+ //let rects =
+ svgGroups.enter()
+ .append('rect')
+ .attr('rx', 8)
+ .attr('ry', 8)
+ .attr('class', 'group')
+ .attr('data-group-id', (d) => d._osid)
+ .style('fill', function (_d, _i) { return 'lightblue'; })
+ .call(drag)
+ .on('click', function (d) {
+ console.log('click', d);
+ d.isExpanded = !d.isExpanded;
+ onRenderViewReq();
+ });
+
+ svgGroups.exit()
+ .remove();
+
+ return svgGroups;
+}
+function calcViewGraph(graph, prevViewGraph) {
+ let {groups, rejectedGroups} = calcGroupsAndRejectedGroups(graph.groups);
+ let newClosedGroupNodes = calcClosedGroupsNodes(rejectedGroups, prevViewGraph.nodes);
+ let {nodes, rejectedNodes} = calcNodesAndRejectedNodes(graph.nodes, graph.groups);
+ nodes = R.concat(newClosedGroupNodes, nodes);
+
+ let {links, rejectedSourceLinks, rejectedTargetLinks, rejectedBothLinks} =
+ calcLinksAndRejectedLinks(graph.links, rejectedNodes);
+
+ let newLinksForRejectedSource =
+ calcNewLinksForRejectedSource(rejectedSourceLinks, nodes, prevViewGraph.links);
+
+ let newLinksForRejectedTarget =
+ calcNewLinksForRejectedTarget(rejectedTargetLinks, nodes, prevViewGraph.links);
+
+ let newLinksForRejectedBoth =
+ calcNewLinksForRejectedBoth(rejectedBothLinks, nodes, prevViewGraph.links);
+
+ links = R.pipe(
+ R.concat(newLinksForRejectedSource),
+ R.concat(newLinksForRejectedTarget),
+ R.concat(newLinksForRejectedBoth)
+ )(links);
+
+ return {
+ nodes,
+ links,
+ groups
+ };
+}
+
+function calcGroupsAndRejectedGroups(originalGroups) {
+ let rejectedGroups = R.filter(R.propEq('isExpanded', false), originalGroups);
+ let groups = R.reject(R.propEq('isExpanded', false), originalGroups);
+
+ return { groups, rejectedGroups };
+}
+
+function calcClosedGroupsNodes(rejectedGroups, prevViewNodes) {
+ return R.reduce((acc, group) => {
+ let nodeId = `${group._osid}-group-node`;
+ let prevNode = R.find(R.propEq('_osid', nodeId), prevViewNodes);
+ if (prevNode) {
+ return R.append(prevNode, acc);
+ }
+
+ return R.append({
+ _osid: nodeId,
+ _osmeta: {
+ type: 'view_group',
+ nodeId: group._osid,
+ },
+ width: 60,
+ height: 40,
+ name: group._osid
+ }, acc);
+ }, [], rejectedGroups);
+}
+
+function calcNodesAndRejectedNodes(originalNodes, originalGroups) {
+ let rejectedNodes = [];
+ let nodes = R.reject((node) => {
+ let host = R.path(['_osmeta', 'host'], node);
+ if (R.isNil(host)) { return false; }
+
+ let group = R.find(R.propEq('_osid', host), originalGroups);
+ if (R.isNil(group)) { return false; }
+
+ if (group.isExpanded) { return false; }
+
+ rejectedNodes = R.append(node, rejectedNodes);
+ return true;
+ }, originalNodes);
+
+ return { nodes, rejectedNodes };
+}
+
+function calcLinksAndRejectedLinks(originalLinks, rejectedNodes) {
+ return R.reduce((acc, link) => {
+ let sourceRejected = R.contains(link.source, rejectedNodes);
+ let targetRejected = R.contains(link.target, rejectedNodes);
+
+ if (sourceRejected && targetRejected) {
+ acc = R.assoc('rejectedBothLinks', R.append(link, acc.rejectedBothLinks), acc);
+ return acc;
+ }
+
+ if (sourceRejected) {
+ acc = R.assoc('rejectedSourceLinks', R.append(link, acc.rejectedSourceLinks), acc);
+ return acc;
+ }
+
+ if (targetRejected) {
+ acc = R.assoc('rejectedTargetLinks', R.append(link, acc.rejectedTargetLinks), acc);
+ return acc;
+ }
+
+ acc = R.assoc('links', R.append(link, acc.links), acc);
+ return acc;
+ },
+ {links: [], rejectedSourceLinks: [], rejectedTargetLinks: [], rejectedBothLinks: [] },
+ originalLinks);
+}
+
+function calcNewLinksForRejectedSource(rejectedSourceLinks, nodes, prevLinks) {
+ let newLinksForRejectedSource = R.reduce((acc, link) => {
+ let host = R.path(['_osmeta', 'host'], link.source);
+ let groupNodeId = `${host}-group-node`;
+ let newSource = R.find(R.propEq('_osid', groupNodeId), nodes);
+ if (R.isNil(newSource)) {
+ throw 'error in new links for rejected source function';
+ }
+
+ let newLinkId = `${newSource._osid}:${link.target._osid}:rejected-source`;
+
+ let existingLink = R.find(R.propEq('_osid', newLinkId), acc);
+ if (existingLink) {
+ return acc;
+ }
+
+ let prevExistingLink = R.find(R.propEq('_osid', newLinkId), prevLinks);
+ if (prevExistingLink) {
+ return R.append(prevExistingLink, acc);
+ }
+
+ return R.append({
+ source: newSource ,
+ target: link.target,
+ label: link.label,
+ _osid: newLinkId
+ }, acc);
+ }, [], rejectedSourceLinks);
+
+ return newLinksForRejectedSource;
+}
+
+function calcNewLinksForRejectedTarget(rejectedLinks, nodes, prevLinks) {
+ let newLinks = R.reduce((acc, link) => {
+ let host = R.path(['_osmeta', 'host'], link.target);
+ let groupNodeId = `${host}-group-node`;
+ let newTarget = R.find(R.propEq('_osid', groupNodeId), nodes);
+ if (R.isNil(newTarget)) {
+ throw 'error in new links for rejected target function';
+ }
+
+ let newLinkId = `${link.source._osid}:${newTarget._osid}:rejected-target`;
+
+ let existingLink = R.find(R.propEq('_osid', newLinkId), acc);
+ if (existingLink) {
+ return acc;
+ }
+
+ let prevExistingLink = R.find(R.propEq('_osid', newLinkId), prevLinks);
+ if (prevExistingLink) {
+ return R.append(prevExistingLink, acc);
+ }
+
+ return R.append({
+ source: link.source ,
+ target: newTarget,
+ label: link.label,
+ _osid: newLinkId
+ }, acc);
+ }, [], rejectedLinks);
+
+ return newLinks;
+}
+
+function calcNewLinksForRejectedBoth(rejectedLinks, nodes, prevLinks) {
+ let newLinks = R.reduce((acc, link) => {
+ let targetHost = R.path(['_osmeta', 'host'], link.target);
+ let sourceHost = R.path(['_osmeta', 'host'], link.source);
+ let groupSourceNodeId = `${sourceHost}-group-node`;
+ let groupTargetNodeId = `${targetHost}-group-node`;
+
+ if (targetHost === sourceHost) {
+ return acc;
+ }
+
+ let newLinkId = `${sourceHost}:${targetHost}:groups-link`;
+ let existingNewLink = R.find(R.propEq('_osid', newLinkId), acc);
+ if (existingNewLink) {
+ return acc;
+ }
+
+ let prevExistingLink = R.find(R.propEq('_osid', newLinkId), prevLinks);
+ if (prevExistingLink) {
+ return R.append(prevExistingLink, acc);
+ }
+
+ let newSource = R.find(R.propEq('_osid', groupSourceNodeId), nodes);
+ let newTarget = R.find(R.propEq('_osid', groupTargetNodeId), nodes);
+
+ let newLink = {
+ source: newSource,
+ target: newTarget,
+ label: 'hosts link',
+ _osid: newLinkId
+ };
+
+ return R.append(newLink, acc);
+ }, [], rejectedLinks);
+
+ return newLinks;
+}