Cytoscape.js News and tutorials

Mobile applications with Cytoscape.js and Cordova

Table of Contents

  1. Introduction
  2. Setup
  3. Creating a Cordova project
  4. Big changes to index.html
  5. index.js
  6. index.css
  7. graph.js and the Wikipedia API
  8. Graph manipulation
  9. Running the application

Introduction

This post is the final part of a series of Cytoscape.js tutorials written by Joseph Stahl for Google Summer of Code 2016. For previous tutorials, see:

In this tutorial, we’ll be using Cordova and Cytoscape.js to create a mobile app which can create a graph of Wikipedia pages, with each page a node and links between pages as edges.

Setup

Cordova

First, we’ll need to install Cordova globally with npm install cordova@6.3 -g. Using version 6.3 ensures compatibility with this tutorial. Now that Cordova is installed, we can use it to take care of creating the directory for our app.

Note: it’s best to provide your own reverse domain name here because signing applications for testing on iOS requires a unique RDN. Run cordova create cordovacy com.madeUpReverseDomain.cordovacy cordovacy to create a new directory called cordovacy, containing an application named cordovacy and the provided reverse-domain-name. With this done, cd into the new directory.

package.json

As before, we’ll use npm to install parts of our application. However, we’ll only be using npm for Cordova this time; the rest of the application will use a separate package manager, Bower, to manage dependencies and take a setup out of building the application each time—we won’t have to run Browserify. Create a package.json file (npm init can automate this) and the following dependencies:

{
  "name": "cordova-cy",
  "version": "0.1.0",
  "description": "",
  "main": "echo \"cordova app\"",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "cordova run browser"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "bower": "^1.7.9",
    "cordova": "^6.3.0"
  }
}

You may wish to install Bower globally to make calling it easier—you’ll be able to use $ bower instead of $ node_modules/.bin/bower. If you choose to not to install Bower and Cordova globally, prefix any later commands I use with node_modules/bin. Run npm install to install Bower.

bower.json

Now that Bower is installed, we can install the front-end files—CSS and JavaScript—that our application will depend on. Similar to package.json, create a bower.json file by running bower init and answering the questions. Once this is done, we’ll install the necessary files, saving them as we go: bower install cytoscape#2 skeleton font-awesome jquery#2.2.4 cytoscape-qtip qtip2#2.2.1 --save.

By default, Bower will place downloaded files in bower_components/ so to add them to the project, we’ll need to copy them to Cordova’s www/ directory (created during cordova create).

Copy the following files from their various folders in bower_components/, placing them in the proper location inside www/ (js for JavaScript files; css for style sheets):

+-- js/
  +-- cytoscape.js
  +-- cytoscape-qtip.js
  +-- jquery.js
  +-- jquery.qtip.min.js
+-- css/
  +-- font-awesome.css
  +-- jquery.qtip.min.css
  +-- normalize.css
  +-- skeleton.css
+-- fonts/
  +-- FontAwesome.otf
  +-- fontawesome-webfont.* (all fontawesome-webfont files)

Cordova preparation

We’ll need to modify the default files generated by cordova create before we can use them. Most of these changes are related to us not using a framework such as Angular.js; because of this, many files can be simplified.

config.xml

We’ll have to modify config.xml slightly, primarily to update app variables (such as name and developer) but also to change app behavior slightly. Because we’re writing our app as a webpage, mobile platforms may default to “bouncing” the web page if scrolled around, which is undesirable for an app that is supposed to act native. To fix this, insert <preference name="DisallowOverscroll" value="true" /> immediately above the last line of the file (so that it’s within the <widget> block). While the file’s open, modify the description and author properties as you see fit.

index.html

We’ll make many more changes to index.html later but for now, just insert the following line at the beginning of <head>:

  <meta http-equiv="Content-Security-Policy" content="default-src * blob:; style-src 'self' 'unsafe-inline'; script-src 'self' https://*.wikipedia.org 'unsafe-inline' 'unsafe-eval'">

This is not a safe Content Security Policy for a published application (notice how it loads almost anything) but is suitable for a demonstration application where ease-of-development is important. Note the inclusion of *.wikipedia.org as a script source; we’ll be using JSONP for issuing API requests; running the JavaScript returned by Wikipedia requires us to list it as a safe source.

Platforms

By default, Cordova does not give us any platforms (iOS, Android, etc.) to run our application on. This application will focus on developing for Android and iOS but we’ll also include a third platform, browser, which will make testing and debugging general app behavior much easier (of course, testing on emulators and actual devices is still highly recommended after obvious bugs are eliminated!). To add these platforms, run cordova platform add android ios browser --save. This will download the necessary files for each platform and allow you to later use commands like cordova run browser for testing. I am using version 4.1.0 of the browser platform, 5.2.1 of the android platform, and VERSION OF IOS PLATFORM. Because Android and iOS both change API versions frequently, it may be necessary to run, for example, cordova platform add android@5.2.1 to ensure compatibility with this tutorial.

Big changes to index.html

Now that we’ve made Cordova’s security policy more permissive, we’ll move on to adding our own code. Modify <head> to look like the following:

<head>
    <!-- This policy allows everything (eg CSS, AJAX, object, frame, media, etc) except that
      * CSS only from the same origin and inline styles,
      * scripts only from the same origin and inline styles, and eval(). And wikipedia, for JSONP
    -->
  <meta http-equiv="Content-Security-Policy" content="default-src * blob:; style-src 'self' 'unsafe-inline'; script-src 'self' https://*.wikipedia.org 'unsafe-inline' 'unsafe-eval'">

  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
  <link rel="stylesheet" type="text/css" href="css/normalize.css">
  <link rel="stylesheet" type="text/css" href="css/skeleton.css">
  <link rel="stylesheet" type="text/css" href="css/font-awesome.css">
  <link rel="stylesheet" type="text/css" href="css/jquery.qtip.min.css">
  <link rel="stylesheet" type="text/css" href="css/index.css">
  <title>Wikipedia Graph</title>
</head>

In addition to the previously mentioned security policy, you can see that we’ve updated the title and added a number of our own CSS files.

body

We’ll be making so many changes to <body> that it’s probably easiest to delete all existing code and start from scratch.

<body>
  <div id="deviceready">
    <div class="event listening">
      <i class="fa fa-refresh fa-spin"></i>
    </div>
  </div>
  <div class="container">
    <!-- input area -->
    <h1>Tutorial 5</h1>
    <div class="row">
      <input type="text" class="u-full-width" id="pageTitle" placeholder="Wikipedia starting page">
    </div>
    <div class="row">
      <div class="six columns">
        <input type="button" class="button-primary u-full-width" id="submitButton" value="Start graph" type="submit">
      </div>
      <div class="six columns">
        <input type="button" class="button-primary u-full-width" id="redoLayoutButton" value="Redo layout" type="submit">
      </div>
    </div>
    <!-- graph area -->
    <div id="cy"></div>
  </div>

  <script type="text/javascript" src="js/jquery.js"></script>
  <script type="text/javascript" src="js/cytoscape.js"></script>
  <script type="text/javascript" src="js/jquery.qtip.min.js"></script>
  <script type="text/javascript" src="js/cytoscape-qtip.js"></script>
  <script type="text/javascript" src="js/graph.js"></script>
  <script type="text/javascript" src="cordova.js"></script>
  <script type="text/javascript" src="js/index.js"></script>
</body>

You may recognize the various classes (such as container, row, and columns) from Tutorial 4; in fact, we’re reusing a lot of that code because the UI is very similar—an input field and two buttons. Likewise, we’re reusing the Font Awesome loading spinner from previous tutorials. This time it’s in the deviceready section and will appear while the application is loading. As always, we’re using a <div id="cy"> element for holding the graph.

Unlike before, scripts are now at the very end of <body>, saving us from using a DOMContentLoaded listener. cordova.js and js/index.js are necessary for Cordova to function; the remaining files are our own and relate to Cytoscape.js.

Small changes to index.js

Within the js/ folder, there’s a file named index.js that Cordova created for us. We’ll change it some; because we aren’t using any device plugins (GPS, orientation, etc.) we don’t have to wait for a deviceready event. If you compare index.html to the original index.html that Cordova created for us, you’ll notice that there is no longer any element with the received class. Therefore, we’ll remove the associated code from the receivedEvent function in index.js:

  receivedEvent: function(id) {
    var parentElement = document.getElementById(id);
    var listeningElement = parentElement.querySelector('.listening');

    listeningElement.setAttribute('style', 'display:none;');

    console.log('Received Event: ' + id);
  }

The code for our listening class is still present as we are using it for showing the Font Awesome loading icon during application loading and hiding it when loading has finished. The rest of index.js is unchanged.

index.css

We need to make a number of changes to index.css to bring it in line with the many modifications we made to index.html, as well as to make sure our graph has room. Delete the old code and replace it with the following:

html, body {
  height: 100%;
}

#deviceready {
  height: 100%;
  width: 100%;
}

.container {
    display: flex;
    flex-flow: column;
    height: 100%;
}

#deviceready {
  position: absolute;
  left: 0;
  top: 50%;
  width: 100%;
  text-align: center;
  margin-top: -0.5em;
  font-size: 2em;
  color: #fff;
}

#cy {
    flex: 1 1 auto;
}

The common theme here is: use as much space as possible. We can’t rely on automatic height properties here because we want our graph to take up as much space as possible. If we allowed the height to be determined automatically, we’d end up with a 0px tall graph because the <div id="cy"> element does not take up any space by default. We’ll use the same flexbox based layout from Tutorial 4 to help us with filling the phone screen here.

graph.js and the Wikipedia API

Now that we’ve finished getting Cordova prepared and index.html ready for the graph, it’s time to move on to the heart of the application: the graph. As in Tutorial 3, keeping a tab open with the completed graph.js is recommended to see how everything fits together.

Initialization

Add the following code to graph.js (in the js/ folder):

var apiPath = 'https://en.wikipedia.org/w/api.php';

var cy = window.cy = cytoscape({
  container: document.getElementById('cy'),
  style: [
    {
      selector: 'node',
      style: {
        'label': 'data(id)'
      }
    }
  ]
});

We’re starting out simple, first with apiPath, which holds the URL of the MediaWiki API that powers this tutorial. Next, we’ll initialize cy by using the cy container and modifying the default style slightly to show node IDs as labels.

createRequest()

Here the benefits of using Wikipedia’s API (which requires no authentication) becomes apparent; we can send off requests without ever having to worry about API keys and secrets. Also, we only to make a single request to get the information necessary for the graph instead of having to make separate user and followers calls as in Tutorials 3 and 4.

var createRequest = function(pageTitle) {
  var settings = {
    'url': apiPath,
    'jsonp': 'callback',
    'dataType': 'jsonp',
    'data': {
      'action': 'query',
      'titles': pageTitle,
      'prop': 'links',
      'format': 'json'
    },
    'xhrFields': { 'withCredentials': true },
    'headers': { 'Api-User-Agent': 'Cytoscape.js-Tutorial/0.1 (https://github.com/cytoscape/cytoscape.js-tutorials; putyour@email.here)' }
  };

  return $.ajax(settings);
};

Note that we’re only creating the request, not actually sending it yet. We’ll write a function later that sends the request and adds the returned data to the graph.

apiPath, defined earlier, is used here for the URL of the API to be accessed while the specifics of the request are put in the data object. Look at the MediaWiki API description for information about other properties that can be queried, and other API requests besides query that can be made.

jQuery will handle the specifics of creating our API request; for example, our data is a JavaScript object but jQuery will convert that to part of a GET request. It’s important to note the line with 'jsonp': 'callback'; this tells Wikipedia that we’re sending a special type of request (JSONP) which gets around the Same Origin Policy.

parseData()

Next in graph.js is parseData(response), which will convert the data returned from Wikipedia into an array of objects that can be added to Cytoscape.js.

var parseData = function(response) {
  var results = [];
  function makeEdges(sourcePage, links) {
    return links.map(function(link) {
      return {
        data: {
          id: 'edge-' + sourcePage + '-' + link.title,
          source: sourcePage,
          target: link.title
        }
      };
    });
  }

  for (var key in response.query.pages) {
    if ({}.hasOwnProperty.call(response.query.pages, key)) {
      var page = response.query.pages[key];

      // source page (the page with the links on it)
      results.push({
        data: {
          id: page.title
        }
      });

      // nodes
      // see note on apply https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push
      var links = page.links;
      try {
        Array.prototype.push.apply(results, links.map(function(link) {
          return {
            data: {
              id: link.title
            }
          };
        }));

        // edges
        Array.prototype.push.apply(results, makeEdges(page.title, page.links));
      } catch (error) {
        console.log('error adding pages. API request failed?');
      }
    }
  }
  return results;
};

As we work through the returned data, first creating an object for the source page (which may not yet be present in the graph if the graph is newly created), then linked pages, then linkks as edges, we’ll add the objects to the results array. This type of work—basically rearranging values in an array—is ideally suited to the map() function.

Before we can start, we’ll define a function for creating links which is little more than a wrapper around .map() to make sure that the name of the “source” page is available. The following code makes more sense if I first outline the format of the JSON returned from Wikipedia, so I’ll do that now:

{
  "continue": {
    "plcontinue": "736|0|Action-angle_variables",
    "continue": "||"
  },
  "query": {
    "pages": {
      "736": {
        "pageid": 736,
        "ns": 0,
        "title": "Albert Einstein",
        "links": [
          {
            "ns": 0,
            "title": "2dF Galaxy Redshift Survey"
          },
          {
            "ns": 0,
            "title": "A priori and a posteriori"
          }
        ]
      }
    }
  }
}

many more links excluded for brevity

The continue object is used if we’re querying successive pages of the graph; doing so is more complicated and would make the graph quickly unwieldy on mobile devices so we can ignore that section. Next, we have the results of the query in the query object. For our query type, we’ll only get a single page returned (accessed by its pageid; in this case, 736). The pageid will be different for each page and we can’t simply access pages[0] because this is not an array, which leaves us iterating over the keys of the pages object.

for (var key in response.query.pages) {
    if ({}.hasOwnProperty.call(response.query.pages, key)) {
      var page = response.query.pages[key];
      // now it's save to add graph elements
    }
}

link to page about why I’m using {}.hasOwnProperty This is slightly more complex than a foreach loop in other languages because JavaScript will default to giving us not only the keys of this object (in this case, response.query.pages), but also the keys of any prototypes of the object. To account for this, we’ll wrap our code within a check for {}.hasOwnProperty.call(response.query.pages, key).

With this check completed, we can start adding data to results. First, we’ll add the source page to make sure that edges have a source node. Because results is currently empty, we can add to the array using push().

      results.push({
        data: {
          id: page.title
        }
      });

Next, we’ll add the links and edges within a try/catch block to ensure that API errors don’t trip up our application.

      var links = page.links;
      try {
        Array.prototype.push.apply(results, links.map(function(link) {
          return {
            data: {
              id: link.title
            }
          };
        }));

        // edges
        Array.prototype.push.apply(results, makeEdges(page.title, page.links));
      } catch (error) {
        console.log('error adding pages. API request failed?');
      }
    }

As mentioned above, .map() is ideal for this kind of work on nodes. We’ll take an object { "ns": 0, "title": "linkedPageTitle" } and convert it to a Cytoscape.js element in the form { data: { id: 'linkedPageTitle' } }. We’ll add the mapped array to results via push(); however, we’ll need to also use apply() because push() expects a list of arguments rather than a single array. In other words, we’re need to go from push([foo, bar]) to push(foo, bar).

A similar thing is done for the edges, except that we’ll use our helper function makeEdges() to ensure that map() can access the title of the page (which is used as the source of the edge). A final call to return results finishes this function! With this, we can consider the API work to be done; we’ve converted everything into objects Cytoscape.js can use so we don’t need to worry about how the API returns data from this point onwards.

Graph manipulation

With the API-related functions done, it’s time to turn our attention to the functions we’ll be using for adding data to the graph and handling user interactions.

addData()

Now that we’ve created an array of Cytoscape.js elements, we can add them to the graph. However, we’ll get errors if we try to add elements that already exist in the graph (the IDs conflict), so first we’ll need to filter all items that already exist in the graph.

function addData(elementArr) {
  var doesNotContainElement = function(element) {
    // true means graph does not contain element
    if (cy.getElementById(element.data.id).length === 0) {
      return true;
    }
    return false;
  };
  var novelElements = elementArr.filter(doesNotContainElement);
  cy.add(novelElements).nodes().forEach(function(node) {
    node.qtip({
      content: { text: '<iframe src="https://en.m.wikipedia.org/wiki/' + encodeURI(node.id()) + '"/>' },
      // content: { text: 'test' },
      show: false,
      position: {
        my: 'bottom center',
        at: 'top center',
        target: node
      },
      style: {
        classes: 'qtip-bootstrap'
      }
    });
  });
}

The beginning of addData(elementArr) defines a function which we’ll use for removing elements from our array that already exist in the graph. cy.getElementById() is the fastest way to determine if elements exist in the graph; if the length of the resulting collection is 0 then we return true to indicate that the graph does not contain any element with that ID.

We’ll use this function with Arrays.prototype.filter() to filter out all elements that already exist in the graph. With this completed, we can add the elements with cy.add(novelElements). cy.add() returns a collection of the elements just added to Cytoscape (now as graph elements rather than objects in an array). This allows us to chain the .add() call with .nodes() (to prevent qTips from being added to edges) and .forEach() to add the qTips to each node.

The qTip for each node is added via node.qtip({ options }). We’ll specify a few options here:

  • content: { text: '<iframe src="https://en.m.wikipedia.org/wiki/' + encodeURI(node.id()) + '"/>' }
    • qTip can display HTML inside each tooltip; we’ll take advantage of this by displaying the Wikipedia page associated with the node. Helpfully, the node ID is also the page title so we’ll encode the title (i.e. from Albert Einstein to Albert%20Einstein) and append it to the mobile Wikipedia URL.
  • position: { ... }
    • This centers the qTip box above each node
  • style: { ... }
    • Style expects classes to be one of the styles specified in jquery.qtip.min.css. In this case, we’ll use the bootstrap style.

addThenLayout(): combining steps

Each time we add data to the graph, we’ll want to run a layout function as well. This way, nodes are spread out and readable.

var addThenLayout = function(response) {
  var elements = parseData(response);
  addData(elements);
  cy.layout({
    name: 'cose'
  });
};

response is the object returned by our jQuery AJAX call. We’ll parse the response with parseData(response), then add the elements (also filtering them) with addData(elements). Finally, we’ll run a layout on the graph with cy.layout(). The cose layout works well here because the force-directed algorithm allows us to see how links “cluster” around a single page with many edges possible for pages linked to from many sources.

Event Handlers

We’ll use two events on this graph: a short tap for displaying the qTip, and a long tap for fetching all links of the selected page.

Short taps

A short tap will send a tap event which we can bind to with cy.on().

cy.on('tap', 'node', function(event) {
  event.preventDefault();
  event.stopPropagation();
  var node = event.cyTarget;
  node.qtip('api').show();

  // deselect old node
  cy.nodes(':selected').unselect();
  node.select();
});

We’ll stop the default action and stop propagation with event.preventDefault() and event.stopPropagation(). This is recommneded because we will be using our own behavior for the tap event and will not want other actions occuring. The target of the tap action is accessible with [event.cyTarget]. Because all nodes in the graph have associated qTips, we can access the qTip API with node.qtip('api'), which will return the qTip API. The API allows us to manipulate the qTips on the graph; in this case, we’ll call show() to unhide the qTip for the tapped node.

The call to preventDefault() at the beginning of this function means we need to perform the default action ourselves—deselecting the old node and selecting the new node. The previously selected node is accessible with the ':selected' selector and can be unselected with unselect(). Next, the node that was tapped on is selected with select().

Long taps

Long taps will issue an AJAX call to get the links from the curernt page.

cy.on('taphold', 'node', function(event) {
  event.preventDefault();
  event.stopPropagation();
  var node = event.cyTarget;
  createRequest(node.id()).done(addThenLayout);

  // deselect old node
  cy.nodes(':selected').unselect();
  node.select();
});

The code is similar to the short-tap code in that we’re preventing the default actions and instead doing the unselection and selection ourselves. However, instead of showing a qTip we’re making an AJAX request and sending the response to addThenLayout(). createRequest() expects the title of a page which is accessible with node.id() (keep in mind we are using the Cytoscape.js elements, not the objects created in parseResponse, so we cannot use node.data.id). done() is a jQuery function that will send the response of an AJAX query to the specified callback function; in this case, addThenLayout().

Buttons

The final step of editing graph.js is is to add event listeners for button presses—important so we can actually start the graph!

submitButton

var submitButton = document.getElementById('submitButton');
submitButton.addEventListener('click', function() {
  cy.elements().remove();
  var page = document.getElementById('pageTitle').value;
  if (page) {
    createRequest(page).done(addThenLayout);
  }
});

Adding a listener is done in the standard way, by getting the submitButton element from index.html and adding an event listener for the 'click' event. In the listener function, first we want to clear the graph by removing all elements so that old graphs don’t interfere. Next, we’ll get the value of the input text box (which has the ID pageTitle) and make sure that the field contains text (i.e. is not empty). If the box does contain text, we’ll go ahead with making a request, just like how we did in the long-tap listener.

redoLayoutButton

var redoLayoutButton = document.getElementById('redoLayoutButton');
redoLayoutButton.addEventListener('click', function() {
  cy.layout({ name: 'cose' });
});

Sometimes, redoing the layout is necessary; for example, if the graph becomes lost off-screen. We’ll select the redoLayoutButton from index.html and handle click events with a one line function: running the cose layout—the same layout we run when adding new elements to the graph.

Running

With all the code complete, we can move forward to running our application. First, I recommend testing in a browser with cordova run browser or npm start to make sure that the code is working properly (since we are not using any Cordova plugins, the browser experience should be near-identical to device experience). I’ve also added a start script to npm so that npm start is effectively an alias of npm run browser. If Cordova is not installed globally, using npm start will save you from having to preface the run command with node_modules/.bin/. Once you’re satisfied with how the application runs in a browser, it’s time to start testing on devices!

Android

For running on Android, either connect an Android phone with USB debugging enabled, or start an emulator. The task of setting up an Android emulator is beyond the scope of this tutorial but good resources are available. If running on a physical device, make sure Android is version 6.0 (Marshmallow) or later because the application targets API level 23. For running an emulator, make sure the emulator is API level 23 or later.

Once device debugging is ready (test with adb devices), make sure that your device appears in the list of devices attached. Then, run cordova run android to automatically compile the app and deploy it to the connected device. If everything has gone properly, you should see the application open after about 20 seconds of compiling and deploying!

iOS

Note: instructions taken from Cordova website which includes some screenshots for help

To build the app for iOS, a Mac is required for using XCode. Install XCode from the Mac App Store and launch it to accept the user agreement before proceeding with installing packages from npm. Once XCode is setup, Cordova requires a few related tools to be installed by running xcode-select --install from the Terminal.

Next, we’ll install some packages from npm to get Cordova talking to XCode. Run npm install -g ios-sim and npm install -g ios-deploy to install the required packages. Of course, the app will need to target iOS so run cordova platform add --save ios if you haven’t already. Once the platform is installed, run cordova build ios to build the iOS application (required so that XCode can run it). With the application built, open the CorcovaCy.xcodeproj file (in the platforms/ directory) in XCode. Now, you can select the target device (I’ve used an iPhone 6S simulator for testing) and hit the play icon to launch the app in the simulator.

Deploying to an actual device is more complicated, requiring registering for a code signing key from Apple. If you are interested in deploying an app to iOS, I recommend reading the links on Cordova’s page about deploying to a device.

iOS also defaults to using a lower-performance JavaScript engine (UIWebView) in Cordova apps; if you are building a commercial application and need higher performance, look into using the Telerik WKWebView Plugin.

Debugging

In the event that you change some of the tutorial code, you’re able to debug the application while it runs on Android by using Chrome’s remote debugging feature. The application is running through the Chrome WebView (TODO: is this the right syntax) on Android which means that desktop versions of Chrome can debug the application running on a connected device by openning Chrome Developer Tools and using “Inspect Devices”. This will all you to, for example, run cy.nodes() to see what Wikipedia pages have been added to the graph.