diff --git a/src/citationnet/static/force-citationNet.js b/src/citationnet/static/force-citationNet.js
new file mode 100644
index 0000000..f3853e2
--- /dev/null
+++ b/src/citationnet/static/force-citationNet.js
@@ -0,0 +1,419 @@
+import * as THREE from './three.module.js';
+import * as Lut from './lut.js';
+
+/**
+* Async function for fetching and parsing JSON files
+* @param {String} path - path or url to JSON file
+* @returns {object} parsed JSON object
+*/
+async function fetchJSON(data) {
+ // console.log('parsed', data)
+
+ try {
+ // waits until the request completes...
+ const retdata = await JSON.parse(data);
+ } catch (error) {
+ console.log(data);
+ throw error;
+ }
+ return retdata;
+}
+
+
+class CitationNet {
+
+ /**
+ * Constructs a new CitationNet object, but does not initialize it. Call object.initialize() right after this.
+ * @param {String} jsondata - path or url to citation data as JSON file/stream
+ */
+ constructor(jsondata = null) {
+ // if jsonPath is not provided try to get globJsonPath, show alert if fails
+ try {
+ if (!(jsondata)) jsondata = jsondata;
+ } catch (error) {
+ alert("no JSON containing citation data specified, graph cannot be displayed");
+ }
+
+ this.jsondata = jsondata;
+ this.is_initialized = false;
+ }
+
+ /**
+ * Fetches and processes data, initializes graph and sets view. Constructors cannot be async in JS, which is needed for fetching and saving data.
+ */
+ async initialize(make_cylinder = false) {
+ this.container = document.getElementById('3d-graph');
+ // fetch data (async)
+ // this.data = await fetchJSON(this.jsonPath);
+ // this.processData();
+ await this.getStats();
+ this.makeGraph(this.data);
+
+ // variables for control toggle-functions
+ this.nodeSize = false;
+ this.edgesOnlyInput = true;
+ this.distanceFromInputNode = false;
+
+ this.adaptWindowSize();
+ this.view('side');
+ this.toggleNodeSize();
+
+ if (make_cylinder) {
+ this.makeCylinder();
+ this.graph.controls()._listeners.change.push(this.renderLabels());
+ }
+
+ this.is_initialized = true;
+ }
+
+ /**
+ * Reads current window size, adapts canvas size and camera projection settings to it.
+ */
+ adaptWindowSize() {
+ this.graph.height(window.innerHeight - document.getElementsByClassName("navbar")[0].scrollHeight);
+ this.graph.width(window.innerWidth);
+
+ this.graph.camera().left = this.graph.width() / -2;
+ this.graph.camera().right = this.graph.width() / 2;
+ this.graph.camera().top = this.graph.height() / 2;
+ this.graph.camera().bottom = this.graph.height() / -2;
+ this.graph.camera().updateProjectionMatrix();
+ }
+
+ /**
+ * Instantiate and configure graph object
+ * @returns {function} Brief description of the returning value here.
+ */
+ makeGraph() {
+ // sort nodes descending by data.nodes.attributes['ref-by-count'], get first items 'ref-by-count'-attribute
+ const maxCites = this.data.nodes.sort((a, b) => (a.attributes['ref-by-count'] > b.attributes['ref-by-count']) ? -1
+ : (a.attributes['ref-by-count'] < b.attributes['ref-by-count']) ? 1
+ : 0)[0].attributes['ref-by-count'];
+
+ // make Graph object
+ this.graph = ForceGraph3D({
+ "controlType": 'trackball',
+ // turn off antaliasing and alpha for improved performance
+ "rendererConfig": { antialias: false, alpha: false }
+ })(this.container)
+ .graphData({ nodes: this.data.nodes, links: this.data.edges })
+ .nodeId('id')
+ .nodeLabel(node => {
+ var doi = (typeof node.attributes.doi !== 'undefined') ? node.attributes.doi : node.id;
+ var category_for = (typeof node.attributes.category_for !== 'undefined') ? ", FOR: " + node.attributes.category_for : "";
+ return `${doi} @ ${node.attributes.nodeyear}\n
cited ${node.attributes['ref-by-count']} times${category_for}`;
+ }
+ )
+ .nodeRelSize(0.5)
+ .nodeColor(node => fieldOfResearchDivisions[node.attributes.category_for.split(';').map(fors => fors.split(':')).sort((a,b)=>a[1] < b[1])[0][0]][1])
+ .nodeOpacity(0.8)
+ .nodeVal(node => node.attributes['ref-by-count']) // size based on citation count, changed using this.toggleNodeSize()
+ .d3Force('center', null) // disable center force
+ .d3Force('charge', null) // disable charge force
+ .d3Force('radialInner', d3.forceRadial(0).strength(0.1)) // weak force pulling the nodes towards the middle axis of the cylinder
+
+ // force pulling the nodes towards the outer radius of the cylinder, strength is dynamic (, look at strengthFuncFactory for details)
+ .d3Force('radialOuter', d3.forceRadial(100).strength(CitationNet.strengthFuncFactory(0.0, 1.0, 0, 200)))
+
+ .enableNodeDrag(false)
+ .onNodeClick(node => {
+ var doi = (typeof node.attributes.doi !== 'undefined') ? node.attributes.doi : node.id;
+ window.open(`https://doi.org/${doi}`);
+ }) // open using doi when node is clicked
+ ;
+
+ // somehow this needs to be done after graph instantiated or else it breaks layouting
+ this.graph.d3Force('link', this.graph.d3Force('link').strength(0.0)); // show edges, but set strength to 0.0 -> no charge/spring forces
+
+ // vertical positioning according to year of publication
+ this.graph.graphData().nodes.forEach((node) => {
+ if (node.attributes.nodeyear >= this.inputNode.attributes.nodeyear) {
+ node.fz = 5 * (node.attributes.nodeyear - this.inputNode.attributes.nodeyear);
+ } else {
+ node.fz = 5 * (node.attributes.nodeyear - this.inputNode.attributes.nodeyear);
+ }
+ });
+ this.inputNode.fz = 0;
+ document.getElementById("btnDistanceFromInputNode").style.fontWeight = "normal";
+ this.distanceFromInputNode = false;
+
+ return this.graph;
+ }
+
+ /**
+ * Function factory for dynamic strength functions using linear interpolation. If input is outside the interval minStrength or maxStrength is used.
+ * @param {number} minStrength - minimum strength, default = 0.0
+ * @param {number} maxStrength - maximum strength, default = 1.0
+ * @param {number} min - lower interval boundary, default = 0.0
+ * @param {number} min - upper interval boundary, default = 100.0
+ * @param {number} exp - exponent (used for adjusting spacing between nodes), default = 1.0
+ * @returns {function} Interpolation function
+ */
+ static strengthFuncFactory(maxStrength, minStrength = 0.0, min = 0, max = 100, exp = 1.0) {
+
+ let strengthFunc = function (node, i, nodes) {
+ let x = node.attributes['ref-by-count'];
+ // linear interpolation
+ let out = minStrength + (x - min) * (maxStrength - minStrength) / (max - min);
+
+ // return minStrength if out smaller than minStrength
+ // return maxStrength if out larger than maxStrength
+ // return out **
+ return out <= minStrength ? minStrength
+ : out >= maxStrength ? maxStrength
+ : out ** exp;
+ };
+ return strengthFunc;
+ }
+
+ /**
+ * Preprocess this.data
+ */
+ processData() {
+ var data = this.data;
+ var id_map = {};
+
+ // find input node
+ this.inputNode = data.nodes.filter(o => o.attributes.is_input_DOI == true)[0];
+ var inputNode = this.inputNode;
+
+ for (let i = 0; i < data.nodes.length; i++) {
+ id_map[data.nodes[i].id] = i;
+ data.nodes[i].outgoingLinks = [];
+ data.nodes[i].outgoingLinkTo = [];
+ data.nodes[i].incomingLinks = [];
+ data.nodes[i].incomingLinkFrom = [];
+
+ // delete unused attributes
+ delete data.nodes[i].color;
+ delete data.nodes[i].size;
+ delete data.nodes[i].x;
+ delete data.nodes[i].y;
+
+ // fix z-coordinate of nodes depending on publication year
+ // 20 units between input node and years before/after
+ // 5 units spacing between years
+ if (data.nodes[i].attributes.nodeyear >= inputNode.attributes.nodeyear) {
+ data.nodes[i].fz = 5 * (data.nodes[i].attributes.nodeyear - inputNode.attributes.nodeyear + 20);
+ } else {
+ data.nodes[i].fz = 5 * (data.nodes[i].attributes.nodeyear - inputNode.attributes.nodeyear - 20);
+ }
+ }
+
+ // fix position of input node, color red
+ inputNode.fx = 0.0;
+ inputNode.fy = 0.0;
+ inputNode.fz = 0.0;
+ inputNode.color = 'red';
+
+ // cross-link node objects
+ data.edges.forEach(edge => {
+ var a = data.nodes[id_map[edge.source]];
+ var b = data.nodes[id_map[edge.target]];
+ if (typeof a != "undefined" && typeof b != "undefined") {
+ !a.outgoingLinks && (a.outgoingLinks = []);
+ a.outgoingLinks.push(edge);
+
+ !a.outgoingLinkTo && (a.outgoingLinkTo = []);
+ a.outgoingLinkTo.push(b);
+
+ !b.incomingLinks && (b.incomingLinks = []);
+ b.incomingLinks.push(edge);
+
+ !b.incomingLinkFrom && (b.incomingLinkFrom = []);
+ b.incomingLinkFrom.push(a);
+
+ delete edge.color;
+ delete edge.size;
+ }
+ }
+ );
+ }
+
+ /**
+ * Move camera to default view point. Triggered by UI.
+ * @param {String} viewPoint - either "top" or "side"
+ * @returns {ReturnValueDataTypeHere} Brief description of the returning value here.
+ */
+ view(viewPoint) {
+ if (viewPoint == 'top') {
+ // indicate view point using bold font
+ document.getElementById("btnTopView").style.fontWeight = "bold";
+ document.getElementById("btnSideView").style.fontWeight = "normal";
+ // set camera position, zoom and viewing direction (up-vector)
+ this.graph.cameraPosition({ x: 0, y: 0, z: 500 }, { x: 0, y: 0, z: 0 }, 500);
+ this.graph.camera().up.set(0.0, 1.0, 0.0);
+ this.graph.camera().zoom = 2.0;
+ this.graph.camera().updateProjectionMatrix();
+ } else if (viewPoint == 'side') {
+ // indicate view point using bold font
+ document.getElementById("btnSideView").style.fontWeight = "bold";
+ document.getElementById("btnTopView").style.fontWeight = "normal";
+ // set camera position, zoom and viewing direction (up-vector)
+ this.graph.cameraPosition({ x: 0, y: -500, z: 0 }, { x: 0, y: 0, z: 0 }, 500);
+ this.graph.camera().up.set(0.0, 0.0, 1.0);
+ this.graph.camera().zoom = 1.0;
+ this.graph.camera().updateProjectionMatrix();
+ }
+ }
+
+ /**
+ * Toggle on/off relative node size by number of citations. Triggered by UI.
+ */
+ toggleNodeSize() {
+ if (this.nodeSize) {
+ // indicate state using bold font
+ document.getElementById("btnNodeSize").style.fontWeight = "normal";
+ // set constant nodeVal
+ this.graph.nodeVal(1.0);
+ this.nodeSize = false;
+ } else {
+ // indicate state using bold font
+ document.getElementById("btnNodeSize").style.fontWeight = "bolder";
+ // set ref-by-count attribute as nodeVal
+ this.graph.nodeVal(node => node.attributes['ref-by-count']);
+ this.nodeSize = true;
+ }
+ }
+
+ /**
+ * Read relative node size from range slider and apply to graph. Triggered by UI.
+ */
+ readNodeSize() {
+ var size = document.getElementById("rngNodeSize").value;
+ this.graph.nodeRelSize(size);
+ }
+
+
+ /**
+ * Read layout options from range sliders and apply to graph. Triggered by UI.
+ */
+ readLayout() {
+ var radius = document.getElementById("rngLayoutRadius").value;
+ var outerValue = document.getElementById("rngLayoutOuterValue").value;
+
+ // set constant strength if outerValue == 0 (-> all nodes are moved towards outer shell)
+ if (outerValue == 0) {
+ this.graph.d3Force('radialOuter', d3.forceRadial(radius).strength(1.0));
+ } else {
+ this.graph.d3Force('radialOuter', d3.forceRadial(radius).strength(CitationNet.strengthFuncFactory(0.0, 1.0, 0, outerValue)));
+ }
+ console.log("reheating");
+ this.graph.d3ReheatSimulation();
+ }
+
+ /**
+ * Toggle on/off viewing only edges that connect to input node directly. Triggered by UI.
+ */
+ toggleEdgesOnlyInput() {
+ if (this.edgesOnlyInput) {
+ this.graph.graphData({ nodes: this.data.nodes, links: this.data.edges });
+ document.getElementById("btnEdgesOnlyInput").style.fontWeight = "normal";
+ this.edgesOnlyInput = false;
+ } else {
+ // get all edges, filter edges that directly attach to input node
+ var edges = this.data.edges;
+ var filteredEdges = edges.filter(edge => edge.source.id == this.inputNode.id || edge.target.id == this.inputNode.id);
+ // display all nodes but only filtered edges
+ this.graph.graphData({ nodes: this.data.nodes, links: filteredEdges });
+ document.getElementById("btnEdgesOnlyInput").style.fontWeight = "bold";
+ this.edgesOnlyInput = true;
+ }
+ }
+
+ toggleDistanceFromInputNode() {
+ let nodes = this.graph.graphData().nodes;
+ if (this.distanceFromInputNode) {
+ nodes.forEach((node) => {
+ if (node.attributes.nodeyear >= this.inputNode.attributes.nodeyear) {
+ node.fz = 5 * (node.attributes.nodeyear - this.inputNode.attributes.nodeyear);
+ } else {
+ node.fz = 5 * (node.attributes.nodeyear - this.inputNode.attributes.nodeyear);
+ }
+ });
+ this.inputNode.fz = 0;
+ document.getElementById("btnDistanceFromInputNode").style.fontWeight = "normal";
+ this.distanceFromInputNode = false;
+ } else {
+ nodes.forEach((node) => {
+ if (node.attributes.nodeyear >= this.inputNode.attributes.nodeyear) {
+ node.fz = 5 * (node.attributes.nodeyear - this.inputNode.attributes.nodeyear + 20);
+ } else {
+ node.fz = 5 * (node.attributes.nodeyear - this.inputNode.attributes.nodeyear - 20);
+ }
+ });
+ this.inputNode.fz = 0;
+ document.getElementById("btnDistanceFromInputNode").style.fontWeight = "bold";
+ this.distanceFromInputNode = true;
+ }
+ console.log("reheating");
+ this.graph.d3ReheatSimulation();
+ }
+
+ /**
+ * Get field of research statistics
+ * @returns {Array}
+ */
+ async getStats() {
+ this.data = await fetchJSON(this.jsondata);
+ this.processData();
+
+ var nodes = this.data.nodes;
+ // var nodes = this.graph.graphData().nodes
+ this.stats = [];
+ // var cumulative = 0.0;
+
+ for (var division in fieldOfResearchDivisions){
+ const reducer = (accumulator, curr) => accumulator + curr;
+ var divnodesContain = nodes.filter(node => node.attributes.category_for.includes(division + ':'));
+ // console.log(divnodesContain)
+ var divnodesElements = divnodesContain.map(node => node.attributes.category_for.split(';')).flat(1);
+ // console.log(divnodesElements)
+ var divnodes = divnodesElements.filter(forcode => forcode.includes(division));
+ // console.log(divnodes)
+ var divnumVals = divnodes.map(elem => elem.split(':')[1]);
+ // console.log(divnumVals)
+ // const myArray = text.split(";").filter(forcode => forcode.includes('03')).map(forcode => forcode.split(':')[1]).map(val => parseFloat(val)).reduce(reducer, 0);
+ var x = divnumVals.map(val => parseFloat(val)).reduce(reducer, 0)/nodes.length;
+ // console.log(division, x)
+ this.stats.push({ 'category': fieldOfResearchDivisions[division][0], 'color': fieldOfResearchDivisions[division][1], 'value': x, 'amount': divnodesContain.length });
+ // cumulative += x
+ }
+
+ return this.stats;
+ }
+}
+
+
+
+const lutFOR = new Lut.Lut('rainbow', 22);
+lutFOR.setMax(22);
+const lutGray = new Lut.Lut('grayscale', 10);
+
+var fieldOfResearchDivisions = {
+ '01': ['01 Mathematical Sciences', lutFOR.getColor(22).getHexString()],
+ '02': ['02 Physical Sciences', lutFOR.getColor(21).getHexString()],
+ '03': ['03 Chemical Sciences', lutFOR.getColor(3).getHexString()],
+ '04': ['04 Earth Sciences', lutFOR.getColor(4).getHexString()],
+ '05': ['05 Environmental Sciences', lutFOR.getColor(5).getHexString()],
+ '06': ['06 Biological Sciences', lutFOR.getColor(6).getHexString()],
+ '07': ['07 Agricultural and Veterinary Sciences', lutFOR.getColor(7).getHexString()],
+ '08': ['08 Information and Computing Sciences', lutFOR.getColor(8).getHexString()],
+ '09': ['09 Engineering', lutFOR.getColor(9).getHexString()],
+ '10': ['10 Technology', lutFOR.getColor(10).getHexString()],
+ '12': ['12 Built Environment and Design', lutFOR.getColor(11).getHexString()],
+ '11': ['11 Medical and Health Sciences', lutFOR.getColor(12).getHexString()],
+ '13': ['13 Education', lutFOR.getColor(13).getHexString()],
+ '14': ['14 Economics', lutFOR.getColor(14).getHexString()],
+ '15': ['15 Commerce, Management, Tourism and Services', lutFOR.getColor(15).getHexString()],
+ '16': ['16 Studies in Human Society', lutFOR.getColor(16).getHexString()],
+ '17': ['17 Psychology and Cognitive Sciences', lutFOR.getColor(17).getHexString()],
+ '18': ['18 Law and Legal Studies', lutFOR.getColor(18).getHexString()],
+ '19': ['19 Studies in Creative Arts and Writing', lutFOR.getColor(19).getHexString()],
+ '20': ['20 Language, Communication and Culture', lutFOR.getColor(20).getHexString()],
+ '21': ['21 History and Archaeology', lutFOR.getColor(2).getHexString()],
+ '22': ['22 Philosophy and Religious Studies', lutFOR.getColor(1).getHexString()],
+ '00': ['00 No FOR code', lutGray.getColor(0.5).getHexString()],
+};
+
+export { CitationNet };
diff --git a/src/citationnet/static/pieChart_vega-lite.js b/src/citationnet/static/pieChart_vega-lite.js
new file mode 100644
index 0000000..1ada90a
--- /dev/null
+++ b/src/citationnet/static/pieChart_vega-lite.js
@@ -0,0 +1,88 @@
+const forChartDiv = document.getElementById("forChartDiv");
+const forChartDivvis = document.getElementById("forChartDivvis");
+const forChartDivloader = document.getElementById("forChartDivloader");
+
+dragElement(forChartDiv);
+
+function toggleFORChart() {
+ if (forChartDiv.hidden) {
+ showFOR();
+ } else {
+ hideFOR();
+ }
+}
+
+async function showFOR() {
+ document.getElementById("btnFORChart").style.fontWeight = "bolder";
+ forChartDivvis.hidden = true;
+ forChartDivloader.hidden = false;
+ forChartDiv.hidden = false;
+
+ await loadFOR();
+
+ forChartDivvis.hidden = false;
+ forChartDivloader.hidden = true;
+}
+
+function hideFOR() {
+ document.getElementById("btnFORChart").style.fontWeight = "normal";
+ forChartDiv.hidden = true;
+}
+
+async function loadFOR() {
+ const stats = await window.net.getStats();
+ const resp = await fetch("/static/vegaLiteSpec.json");
+ const vegaLiteSpec = await resp.json();
+ const domain = stats.map(value => value.category);
+ const range = stats.map(value => "#".concat(value.color));
+
+ vegaLiteSpec.data.values = stats;
+ vegaLiteSpec.encoding.color.scale.domain = domain;
+ vegaLiteSpec.encoding.color.scale.range = range;
+
+ console.log(vegaLiteSpec);
+
+ vegaEmbed('#forChartDivvis', vegaLiteSpec);
+}
+
+function dragElement(elmnt) {
+ var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
+ if (document.getElementById(elmnt.id + "header")) {
+ /* if present, the header is where you move the DIV from:*/
+ document.getElementById(elmnt.id + "header").onmousedown = dragMouseDown;
+ } else {
+ /* otherwise, move the DIV from anywhere inside the DIV:*/
+ elmnt.onmousedown = dragMouseDown;
+ }
+
+ function dragMouseDown(e) {
+ e = e || window.event;
+ e.preventDefault();
+ // get the mouse cursor position at startup:
+ pos3 = e.clientX;
+ pos4 = e.clientY;
+ document.onmouseup = closeDragElement;
+ // call a function whenever the cursor moves:
+ document.onmousemove = elementDrag;
+ }
+
+ function elementDrag(e) {
+ e = e || window.event;
+ e.preventDefault();
+ // calculate the new cursor position:
+ pos1 = pos3 - e.clientX;
+ pos2 = pos4 - e.clientY;
+ pos3 = e.clientX;
+ pos4 = e.clientY;
+ // set the element's new position:
+ // elmnt.style.right = "";
+ elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
+ elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
+ }
+
+ function closeDragElement() {
+ /* stop moving when mouse button is released:*/
+ document.onmouseup = null;
+ document.onmousemove = null;
+ }
+}
diff --git a/src/citationnet/static/vegaLiteSpec.json b/src/citationnet/static/vegaLiteSpec.json
new file mode 100644
index 0000000..b243862
--- /dev/null
+++ b/src/citationnet/static/vegaLiteSpec.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+ "description": "FOR distribution pie chart",
+ "data": {
+ "values": "empty"
+ },
+ "encoding": {
+ "theta": { "field": "value", "type": "quantitative", "stack": true },
+ "color": { "field": "category", "type": "nominal", "legend": null, "scale": {"domain": ["gold", "silver"], "range": ["#F1C40F", "#95A5A6"]}},
+ "tooltip": [
+ { "field": "category", "type": "nominal", "title": "Category (FOR)" },
+ { "field": "value", "type": "quantitative", "format": ".1p", "title": "Share of FOR code"},
+ { "field": "amount", "type": "quantitative", "title": "Number of publications"}
+ ]
+ },
+ "layer": [
+ {
+ "mark": {
+ "type": "arc",
+ "outerRadius": 180
+ }
+ },
+ {
+ "mark": { "type": "text", "radius": 200 },
+ "encoding": {
+ "text": {
+ "field": "value",
+ "type": "quantitative",
+ "format": ".1p",
+ "condition": {
+ "test": "datum['value'] < 0.03", "value":""
+ }
+ }
+ }
+ }
+ ],
+ "background": null
+}
diff --git a/src/citationnet/templates/visDynamic.html b/src/citationnet/templates/visDynamic.html
new file mode 100644
index 0000000..a3776f0
--- /dev/null
+++ b/src/citationnet/templates/visDynamic.html
@@ -0,0 +1,117 @@
+{% extends 'base.html' %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+