diff --git a/client/gulpfile.js b/client/gulpfile.js
index d0fadf4ca4f..3f54027ed25 100644
--- a/client/gulpfile.js
+++ b/client/gulpfile.js
@@ -32,6 +32,7 @@ const STATIC_PLUGIN_BUILD_IDS = [
"pv",
"nora",
"venn",
+ "ts_visjs"
];
const DIST_PLUGIN_BUILD_IDS = ["new_user"];
const PLUGIN_BUILD_IDS = Array.prototype.concat(DIST_PLUGIN_BUILD_IDS, STATIC_PLUGIN_BUILD_IDS);
diff --git a/config/plugins/visualizations/ts_visjs/config/ts_visjs.xml b/config/plugins/visualizations/ts_visjs/config/ts_visjs.xml
new file mode 100644
index 00000000000..697ee498551
--- /dev/null
+++ b/config/plugins/visualizations/ts_visjs/config/ts_visjs.xml
@@ -0,0 +1,16 @@
+
+
+
+ - visjs-based interactive visualization of transition system in graph form.
+
+
+ HistoryDatasetAssociation
+ text.BCSLts
+ dataset_id
+
+
+
+ dataset_id
+
+ ts_visjs.mako
+
diff --git a/config/plugins/visualizations/ts_visjs/package.json b/config/plugins/visualizations/ts_visjs/package.json
new file mode 100644
index 00000000000..5b2a179fcaf
--- /dev/null
+++ b/config/plugins/visualizations/ts_visjs/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "ts_visjs",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "vis": "^4.21.0"
+ },
+ "scripts": {
+ "build": "mkdir -p static/dist && cp -r node_modules/vis/dist/img node_modules/vis/dist/vis.min.js node_modules/vis/dist/vis.min.css static/dist"
+ }
+}
diff --git a/config/plugins/visualizations/ts_visjs/static/icons/border.png b/config/plugins/visualizations/ts_visjs/static/icons/border.png
new file mode 100644
index 00000000000..674c63baeb1
Binary files /dev/null and b/config/plugins/visualizations/ts_visjs/static/icons/border.png differ
diff --git a/config/plugins/visualizations/ts_visjs/static/icons/fire.png b/config/plugins/visualizations/ts_visjs/static/icons/fire.png
new file mode 100644
index 00000000000..149d157d9fd
Binary files /dev/null and b/config/plugins/visualizations/ts_visjs/static/icons/fire.png differ
diff --git a/config/plugins/visualizations/ts_visjs/static/icons/loop.png b/config/plugins/visualizations/ts_visjs/static/icons/loop.png
new file mode 100644
index 00000000000..ef277244b26
Binary files /dev/null and b/config/plugins/visualizations/ts_visjs/static/icons/loop.png differ
diff --git a/config/plugins/visualizations/ts_visjs/static/script.js b/config/plugins/visualizations/ts_visjs/static/script.js
new file mode 100644
index 00000000000..42abe67869e
--- /dev/null
+++ b/config/plugins/visualizations/ts_visjs/static/script.js
@@ -0,0 +1,168 @@
+var options = {
+ layout: {improvedLayout: true},
+ physics: {
+ enabled: true,
+ barnesHut: {
+ gravitationalConstant: -25000,
+ centralGravity: 0.5,
+ springConstant: 0.5,
+ springLength: 200,
+ damping: 0.15
+ },
+ maxVelocity: 50,
+ minVelocity: 7.5,
+ solver: 'barnesHut',
+ timestep: 0.5,
+ stabilization: {
+ enabled: true,
+ iterations: def_iterations,
+ updateInterval: def_updateInterval
+ },
+ },
+ nodes: {
+ size: 15,
+ font: {
+ size: 20
+ },
+ borderWidth: 2,
+ borderWidthSelected: 4,
+ color:{highlight:{border: '#B20F0F', background: 'red'}}
+ },
+ edges: {
+ width: 4,
+ selectionWidth: function (width) {return width*2.5;},
+ color:{color:'#2B7CE9', hover:'#2B7CE9', highlight: 'red'}
+ },
+ interaction: {
+ navigationButtons: true,
+ keyboard: true,
+ hover: true,
+ tooltipDelay: 500,
+ multiselect: true
+ }
+};
+
+var container = document.getElementById('mynetwork');
+
+// data filters
+hell_filter = document.getElementById('hell_node')
+loops_filter = document.getElementById('loop_edges')
+border_filter = document.getElementById('border_nodes')
+
+let noHell = false
+const nodesFilter = (node) => {
+ if (noHell) {
+ return (node.class != 'hell')
+ } else {
+ return true
+ }
+}
+
+var nodesView = new vis.DataView(nodes, { filter: nodesFilter })
+
+// remove hell
+hell_filter.addEventListener('change', (e) => {
+ if (hell_filter.checked){
+ noHell = false
+ } else {
+ noHell = true
+ }
+ nodesView.refresh()
+});
+
+data = {
+ nodes: nodesView,
+ edges: edges
+ };
+
+loops_filter.addEventListener('change', (e) => {
+ if (loops_filter.checked){
+ for (index = 0; index < self_loops.length; index++) {
+ edges.add(self_loops[index]);
+ }
+ } else {
+ for (index = 0; index < self_loops.length; index++) {
+ edges.remove({id: self_loops[index].id});
+ }
+ }
+});
+
+border_filter.addEventListener('change', (e) => {
+ if (border_filter.checked){
+ for (index = 0; index < border_nodes.length; index++) {
+ nodes.update({id: border_nodes[index].id, shape: "box"});
+ }
+ } else {
+ for (index = 0; index < border_nodes.length; index++) {
+ nodes.update({id: border_nodes[index].id, shape: "ellipse"});
+ }
+ }
+});
+
+var stabil = true;
+
+clickedNode = nodes.get(fromNode);
+clickedNode.color = {
+ border: 'orange',
+ background: 'orange',
+ highlight: {
+ border: 'orange',
+ background: 'orange'
+ }
+}
+nodes.update(clickedNode);
+
+window.onresize = function() {
+ var network = new vis.Network(container, data, options);
+
+ network.on("click", function (params) {
+ params.event = "[original event]";
+ var tmp = " ";
+
+
+ for (var i = 1; i <= nodes.length; i++) {
+ if (nodes.get(i).id == params.nodes) {
+ tmp = nodes.get(i).text;
+ };
+ };
+
+ if(params.nodes.length === 0 && params.edges.length > 0) {
+ for (var i = 1; i <= edges.length; i++) {
+ if (edges.get(i).id == params.edges) {
+ tmp = edges.get(i).text;
+ };
+ };
+ };
+
+ document.getElementById('rectangle').innerHTML = '
' + tmp + '
';
+ });
+
+ network.on("stabilized", function (params) {
+ if(stabil) {
+ network.fit();
+ stabil = false;
+ };
+ });
+
+ network.once("stabilizationIterationsDone", function() {
+ document.getElementById('text').innerHTML = '100%';
+ document.getElementById('bar').style.width = '496px';
+ document.getElementById('loadingBar').style.opacity = 0;
+ setTimeout(function () {document.getElementById('loadingBar').style.display = 'none';}, 0);
+ });
+
+ network.on("doubleClick", function (params) {
+ params.event = "[original event]";
+ network.focus(params.nodes);
+ });
+
+ network.on("stabilizationProgress", function(params) {
+ var iterations = def_iterations;
+ var updateInterval = def_updateInterval;
+ var width = 5*(params.iterations/updateInterval);
+ var widthFactor = params.iterations/iterations;
+
+ document.getElementById('bar').style.width = width + 'px';
+ document.getElementById('text').innerHTML = Math.round(widthFactor*100) + '%';
+ });
+};
diff --git a/config/plugins/visualizations/ts_visjs/static/style.css b/config/plugins/visualizations/ts_visjs/static/style.css
new file mode 100644
index 00000000000..53edbbc8552
--- /dev/null
+++ b/config/plugins/visualizations/ts_visjs/static/style.css
@@ -0,0 +1,174 @@
+#mynetwork {
+ width: 94%;
+ height: 100%;
+ border: 1px solid #000;
+ float: left;
+}
+
+#rectangle {
+ text-align: center;
+ font-weight: bold;
+}
+
+#loadingBar {
+ position:absolute;
+ top:1px;
+ left:1px;
+ width: 94%;
+ height: 93%;
+ background-color:rgba(200,200,200,0.8);
+ -webkit-transition: all 0.5s ease;
+ -moz-transition: all 0.5s ease;
+ -ms-transition: all 0.5s ease;
+ -o-transition: all 0.5s ease;
+ transition: all 0.5s ease;
+ opacity:1;
+}
+
+#wrapper {
+ position:relative;
+ width:900px;
+ height:900px;
+}
+
+#text {
+ position:absolute;
+ top:8px;
+ left:530px;
+ width:30px;
+ height:50px;
+ margin:auto auto auto auto;
+ font-size:22px;
+ color: #000000;
+}
+
+div.outerBorder {
+ position:relative;
+ top:400px;
+ width:600px;
+ height:44px;
+ margin:auto auto auto auto;
+ border:8px solid rgba(0,0,0,0.1);
+ background: rgb(252,252,252); /* Old browsers */
+ background: -moz-linear-gradient(top, rgba(252,252,252,1) 0%, rgba(237,237,237,1) 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(252,252,252,1)), color-stop(100%,rgba(237,237,237,1))); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, rgba(252,252,252,1) 0%,rgba(237,237,237,1) 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, rgba(252,252,252,1) 0%,rgba(237,237,237,1) 100%); /* Opera 11.10+ */
+ background: -ms-linear-gradient(top, rgba(252,252,252,1) 0%,rgba(237,237,237,1) 100%); /* IE10+ */
+ background: linear-gradient(to bottom, rgba(252,252,252,1) 0%,rgba(237,237,237,1) 100%); /* W3C */
+ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#fcfcfc', endColorstr='#ededed',GradientType=0 ); /* IE6-9 */
+ border-radius:72px;
+ box-shadow: 0px 0px 10px rgba(0,0,0,0.2);
+}
+
+#border {
+ position:absolute;
+ top:10px;
+ left:10px;
+ width:500px;
+ height:23px;
+ margin:auto auto auto auto;
+ box-shadow: 0px 0px 4px rgba(0,0,0,0.2);
+ border-radius:10px;
+}
+
+#bar {
+ position:absolute;
+ top:0px;
+ left:0px;
+ width:20px;
+ height:20px;
+ margin:auto auto auto auto;
+ border-radius:11px;
+ border:2px solid rgba(30,30,30,0.05);
+ background: rgb(0, 173, 246); /* Old browsers */
+ box-shadow: 2px 0px 4px rgba(0,0,0,0.4);
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ height: 90%;
+}
+
+ /* The switch - the box around the slider */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 60px;
+ height: 34px;
+}
+
+/* Hide default HTML checkbox */
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+/* The slider */
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ -webkit-transition: .4s;
+ transition: .4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 26px;
+ width: 26px;
+ left: 4px;
+ bottom: 4px;
+ background-color: white;
+ -webkit-transition: .4s;
+ transition: .4s;
+}
+
+input:checked + .slider {
+ background-color: #2196F3;
+}
+
+input:focus + .slider {
+ box-shadow: 0 0 1px #2196F3;
+}
+
+input:checked + .slider:before {
+ -webkit-transform: translateX(26px);
+ -ms-transform: translateX(26px);
+ transform: translateX(26px);
+}
+
+/* Rounded sliders */
+.slider.round {
+ border-radius: 34px;
+}
+
+.slider.round:before {
+ border-radius: 50%;
+}
+
+aside {
+ float: right;
+ width: 5%;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+td.switch_td {
+ padding-bottom: 6px;
+}
+
+.switch_button {
+ border-bottom: 2px solid black;
+}
diff --git a/config/plugins/visualizations/ts_visjs/templates/ts_visjs.mako b/config/plugins/visualizations/ts_visjs/templates/ts_visjs.mako
new file mode 100644
index 00000000000..7504667c534
--- /dev/null
+++ b/config/plugins/visualizations/ts_visjs/templates/ts_visjs.mako
@@ -0,0 +1,198 @@
+<%!
+import collections
+import json
+import sys
+from numpy import inf
+%>
+
+<%
+def to_counter(state, ordering):
+ """Transforms given state to Counter using given ordering."""
+ state = eval(state)
+ if state[0] == inf:
+ return inf
+ return +collections.Counter({ordering[i]: state[i] for i in range(len(ordering))})
+
+
+def node_to_string(node):
+ """Creates string representation of Counter of agents."""
+ if node == inf:
+ return "inf"
+ return "
".join([str(int(v)) + " " + str(k) for k, v in node.items()])
+
+
+def side_to_string(side):
+ """Another string representation of Counter, this time used as side in reaction."""
+ if side == inf:
+ return "inf"
+ return " + ".join([str(int(v)) + " " + str(k) for k, v in side.items()])
+
+
+def create_sides(lhs, rhs):
+ """From given substrates and products counters creates their mutual differences."""
+ if lhs == inf:
+ return inf, inf
+ if rhs == inf:
+ return collections.Counter(), inf
+ left = lhs - rhs
+ right = rhs - lhs
+ return left, right
+
+
+def write_node(ID, label, node_class):
+ """Creates string representation of a node."""
+ if label == "inf":
+ node_class = "hell"
+ return "\t\t{{id: {0}, label: '{0}', class: '{2}', shape: 'ellipse', title: '{0}', text: '{1}'}},\n".format(
+ ID, label, node_class)
+
+
+def write_reaction(edge_id, left_index, right_index, substrates, products, rate, label):
+ """Creates string representation of a reaction."""
+ rate = " @ " + str(rate) if rate else ""
+ label = label + " ~ " if label else ""
+ return "\t\t{{id: {}, from: {}, to: {}, arrows: 'to', text: '{}{} => {}{}'}},\n".format(
+ edge_id, left_index, right_index, label, side_to_string(substrates), side_to_string(products), rate)
+
+
+def load_data():
+ output = {"nodes": "", "border_nodes": "", "edges": "", "edges": "", "self_loops": ""}
+
+ data = ''.join(list(hda.datatype.dataprovider(hda, 'line', strip_lines=True, strip_newlines=True)))
+ data = json.loads(data)
+
+ ordering = data['ordering']
+ nodes = {int(key): to_counter(data['nodes'][key], ordering) for key in data['nodes'].keys()}
+
+ border_nodes = set()
+ edges = []
+ self_loops = []
+
+ for edge_id, edge in enumerate(data['edges'], 1):
+ substrates, products = create_sides(nodes[edge['s']], nodes[edge['t']])
+ if edge['s'] == edge['t']:
+ self_loops.append((edge_id, edge['s'], edge['t'], substrates,
+ products, edge.get('p', None), edge.get('label', None)))
+ edges.append((edge_id, edge['s'], edge['t'], substrates, products,
+ edge.get('p', None), edge.get('label', None)))
+ if products == inf and substrates != inf:
+ border_nodes.add(edge['s'])
+
+ border_nodes_write = []
+ for id, state in nodes.items():
+ if id in border_nodes:
+ node_class = "border"
+ border_nodes_write.append((id, node_to_string(state), node_class))
+ else:
+ node_class = "default"
+ output["nodes"] += write_node(id, node_to_string(state), node_class)
+
+ for node in border_nodes_write:
+ output["border_nodes"] += write_node(*node)
+
+ for edge in edges:
+ output["edges"] += write_reaction(*edge)
+
+ for edge in self_loops:
+ output["self_loops"] += write_reaction(*edge)
+
+ iterations = (len(nodes)//100+1) * 100
+
+ output["iterations"] = iterations
+ output["step"] = iterations//100
+ output["fromNode"] = str(int(data['initial']))
+ return output
+
+
+root = h.url_for("/static/")
+app_root = root + "plugins/visualizations/ts_visjs/static/"
+output = load_data()
+%>
+
+## ----------------------------------------------------------------------------
+
+
+
+
+ TS network visualisation
+
+ ${h.javascript_link(app_root + "dist/vis.min.js")}
+ ${h.stylesheet_link(app_root + "dist/vis.min.css")}
+ ${h.stylesheet_link(app_root + "style.css")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${h.javascript_link(app_root + "script.js")}
+
+
+