aboutsummaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/README.md6
-rw-r--r--ui/assoc-day.html44
-rw-r--r--ui/assoc-metric.html45
-rw-r--r--ui/assoc-overview.html43
-rw-r--r--ui/assoc-pair.html47
-rw-r--r--ui/day.html49
-rw-r--r--ui/histograms.html48
-rw-r--r--ui/home.html16
-rw-r--r--ui/metric.html83
-rw-r--r--ui/overview.html59
-rw-r--r--ui/table-lib.js482
-rw-r--r--ui/table-sort.css39
-rw-r--r--ui/ui.css53
-rw-r--r--ui/ui.js363
14 files changed, 1377 insertions, 0 deletions
diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000..1a00c1d
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,6 @@
+ui
+==
+
+This directory contains static HTML, CSS, and JavaScript for the RAPPOR
+dashboard. See the `pipeline/` directory for more details.
+
diff --git a/ui/assoc-day.html b/ui/assoc-day.html
new file mode 100644
index 0000000..2255325
--- /dev/null
+++ b/ui/assoc-day.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Single Day Association Results</title>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initAssocDay(gUrlHash, gTableStates, kStatusElem);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <!-- TODO: up to metric? Nav bar. -->
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <a href="assoc-overview.html">Association Overview</a>
+ </p>
+
+ <!-- NOTE: There is a metric description here. Get it from the XML file.
+ -->
+
+ <h2 id="metricDay"></h2>
+
+ <table id="results_table">
+ </table>
+
+ <p>
+ <!-- link depends on fragment; filled in by JS -->
+ Underlying data: <a id="underlying" href="">assoc-results.csv</a>
+ </p>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/assoc-metric.html b/ui/assoc-metric.html
new file mode 100644
index 0000000..1ac1dde
--- /dev/null
+++ b/ui/assoc-metric.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title></title> <!-- filled in by JS -->
+
+ <script type="text/javascript" src="static/dygraph-combined.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initAssocMetric(gUrlHash, gTableStates, kStatusElem, globals);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <a href="assoc-overview.html">Association Overview</a>
+ </p>
+
+ <h1 id="pageTitle"></h1> <!-- filled in by JS -->
+
+ <p id="metricDesc"></p> <!-- filled in by JS -->
+
+ <table id="metric_table">
+ </table>
+
+ <p>
+ <!-- link depends on fragment; filled in by JS -->
+ Underlying data: <a id="underlying-status" href=""></a>
+ </p>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var globals = {proportionsDygraph: null};
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/assoc-overview.html b/ui/assoc-overview.html
new file mode 100644
index 0000000..e3f06e1
--- /dev/null
+++ b/ui/assoc-overview.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>RAPPOR Association Analysis Overview</title>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initAssocOverview(gUrlHash, gTableStates, kStatusElem);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <p style="text-align: right">
+ <a href="../../live/latest/overview.html">Single variable analysis</a> (latest)
+ </p>
+
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <b>Association Overview</b>
+ </p>
+
+ <h1>RAPPOR Association Analysis Overview</h1>
+
+ <table id="overview">
+ </table>
+
+ <p>
+ Underlying data: <a href="cooked/assoc-overview.csv">overview.csv</a>
+ </p>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/assoc-pair.html b/ui/assoc-pair.html
new file mode 100644
index 0000000..7625966
--- /dev/null
+++ b/ui/assoc-pair.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title></title> <!-- filled in by JS -->
+
+ <script type="text/javascript" src="static/dygraph-combined.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initAssocPair(gUrlHash, gTableStates, kStatusElem, globals);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <a href="assoc-overview.html">Association Overview</a>
+ </p>
+
+ <h1 id="pageTitle"></h1> <!-- filled in by JS -->
+
+ <p id="metricDesc"></p> <!-- filled in by JS -->
+
+ <h2>Task Status</h2>
+
+ <table id="status_table">
+ </table>
+
+ <p>
+ <!-- link depends on fragment; filled in by JS -->
+ Underlying data: <a id="underlying-status" href=""></a>
+ </p>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var globals = {proportionsDygraph: null};
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/day.html b/ui/day.html
new file mode 100644
index 0000000..624778c
--- /dev/null
+++ b/ui/day.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Single Day Results</title>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initDay(gUrlHash, gTableStates, kStatusElem);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <!-- TODO: up to metric? Nav bar. -->
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <a href="overview.html">Overview</a> /
+ <a href="histograms.html">Histograms</a>
+ </p>
+
+ <!-- NOTE: There is a metric description here. Get it from the XML file.
+ -->
+
+ <h2 id="metricDay"></h2>
+
+ <table id="results_table">
+ </table>
+
+ <p>
+ <img id="residual" src="" alt="Residuals">
+ </p>
+
+ <p>
+ <!-- link depends on fragment; filled in by JS -->
+ Underlying data: <a id="underlying" href="">results.csv</a>
+ </p>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/histograms.html b/ui/histograms.html
new file mode 100644
index 0000000..cce5ee2
--- /dev/null
+++ b/ui/histograms.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>RAPPOR Task Histograms</title>
+
+ <!-- TODO: use <base> tag? -->
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body>
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <a href="overview.html">Overview</a> /
+ <b>Histograms</b>
+ </p>
+
+ <h1>RAPPOR Task Histograms</h1>
+
+ <p>Each task's input is a (metric, day), i.e. it runs on the summed reports
+ for a single metric received in a single day.</p>
+
+ <p>
+ <img src="cooked/allocated_mass.png" />
+ </p>
+
+ <p>
+ <img src="cooked/num_rappor.png" />
+ </p>
+
+ <p>
+ <img src="cooked/num_reports.png" />
+ </p>
+
+ <p>
+ <img src="cooked/seconds.png" />
+ </p>
+
+ <p>
+ <img src="cooked/memory.png" />
+ </p>
+
+ <p>
+ <img src="mem-series.png" />
+ </p>
+
+ </body>
+</html>
diff --git a/ui/home.html b/ui/home.html
new file mode 100644
index 0000000..d4f947a
--- /dev/null
+++ b/ui/home.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Rappor HOME</title>
+
+ <!-- This page is a stub that redirects to our Github home page.
+ overview.html, etc. link to it. Redirect after 0 seconds. -->
+ <meta http-equiv="refresh" content="0; url=https://github.com/google/rappor" />
+ </head>
+
+ <body>
+ <p>
+ Redirecting to <a href="https://github.com/google/rappor">https://github.com/google/rappor</a>
+ </p>
+ </body>
+</html>
diff --git a/ui/metric.html b/ui/metric.html
new file mode 100644
index 0000000..ac14a88
--- /dev/null
+++ b/ui/metric.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Metric Results</title>
+
+ <script type="text/javascript" src="static/dygraph-combined.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initMetric(gUrlHash, gTableStates, kStatusElem, globals);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <a href="overview.html">Overview</a> /
+ <a href="histograms.html">Histograms</a>
+ </p>
+
+ <!-- NOTE: There is a metric description here. Get it from the XML file.
+ -->
+
+ <h1 id="metricName"></h1> <!-- filled in by JS -->
+
+ <p id="metricDesc"></p> <!-- filled in by JS -->
+
+ <h2>Estimated Proportions</h2>
+ <p>NOTE: Only the top 5 values for each day are shown</p>
+
+ <!--
+ NOTE: Setting customBars: false removes the entire line? That's lame.
+ <p>
+ <label>
+ <input type="checkbox" checked="checked"
+ onclick="onMetricCheckboxClick(this, globals.proportionsDygraph);">
+ Show Error Bars
+ </label>
+ </p>
+ -->
+ <p class="dy" id="proportionsDy"></p>
+ <p>
+ Underlying data: <a id="underlying-dist" href="">dist.csv</a>
+ </p>
+
+ <h2>Number of Reports</h2>
+
+ <p class="dy" id="num-reports-dy" align="center"></p>
+ <!-- underlying data here is in status.csv? -->
+
+ <h2>Unallocated Mass</h2>
+
+ <p class="dy" id="mass-dy" align="center"></p>
+
+ <p>
+ Plot Help: Drag horizontally to <b>zoom to selection</b>. Double click
+ to <b>zoom out</b>. Shift + drag to <b>pan</b>.
+ </p>
+
+ <h2>Task Status</h2>
+
+ <table id="status_table">
+ </table>
+
+ <p>
+ <!-- link depends on fragment; filled in by JS -->
+ Underlying data: <a id="underlying-status" href="">status.csv</a>
+ </p>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var globals = {proportionsDygraph: null};
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/overview.html b/ui/overview.html
new file mode 100644
index 0000000..464f983
--- /dev/null
+++ b/ui/overview.html
@@ -0,0 +1,59 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>RAPPOR Results Overview</title>
+
+ <link rel="stylesheet" type="text/css" href="static/table-sort.css" />
+ <script type="text/javascript" src="static/table-lib.js"></script>
+
+ <link rel="stylesheet" type="text/css" href="static/ui.css" />
+ <script type="text/javascript" src="static/ui.js"></script>
+ </head>
+
+ <body onload="initOverview(gUrlHash, gTableStates, kStatusElem);"
+ onhashchange="onHashChange(gUrlHash, gTableStates, kStatusElem);">
+ <p id="status"></p>
+
+ <p style="text-align: right">
+ <a href="../../assoc-live/latest/assoc-overview.html">Association analysis</a> (latest)
+ </p>
+
+ <p style="text-align: right">
+ <a href="../home.html">Home</a> /
+ <b>Overview</b> /
+ <a href="histograms.html">Histograms</a>
+ </p>
+
+ <h1>RAPPOR Results Overview</h1>
+
+ <table id="overview">
+ </table>
+
+ <p>
+ Underlying data: <a href="cooked/overview.csv">overview.csv</a>
+ </p>
+
+ <h2>Metric Descriptions</h2>
+
+ <!-- Filled in by JS -->
+ <table id="metricMetadata">
+ <thead>
+ <tr>
+ <td>Metric Name</td>
+ <td>Owners</td>
+ <td>Description</td>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+
+ <!-- page globals -->
+ <script type="text/javascript">
+ var gUrlHash = new UrlHash(location.hash);
+ var gTableStates = {};
+ var kStatusElem = document.getElementById('status');
+ </script>
+
+ </body>
+</html>
diff --git a/ui/table-lib.js b/ui/table-lib.js
new file mode 100644
index 0000000..64913dc
--- /dev/null
+++ b/ui/table-lib.js
@@ -0,0 +1,482 @@
+// Sortable HTML table.
+//
+// Usage:
+//
+// Each page should have gTableStates and gUrlHash variables. This library
+// only provides functions / classes, not instances.
+//
+// Then use these public functions on those variables. They should be hooked
+// up to initialization and onhashchange events.
+//
+// - makeTablesSortable
+// - updateTables
+//
+// Life of a click
+//
+// - query existing TableState object to find the new state
+// - mutate urlHash
+// - location.hash = urlHash.encode()
+// - onhashchange
+// - decode location.hash into urlHash
+// - update DOM
+//
+// HTML generation requirements:
+// - <table id="foo">
+// - need <colgroup> for types.
+// - For numbers, class="num-cell" as well as <col type="number">
+// - single <thead> and <tbody>
+
+'use strict';
+
+function appendMessage(elem, msg) {
+ // TODO: escape HTML?
+ elem.innerHTML += msg + '<br />';
+}
+
+function userError(errElem, msg) {
+ if (errElem) {
+ appendMessage(errElem, msg);
+ } else {
+ console.log(msg);
+ }
+}
+
+//
+// Key functions for column ordering
+//
+// TODO: better naming convention?
+
+function identity(x) {
+ return x;
+}
+
+function lowerCase(x) {
+ return x.toLowerCase();
+}
+
+// Parse as number.
+function asNumber(x) {
+ var stripped = x.replace(/[ \t\r\n]/g, '');
+ if (stripped === 'NA') {
+ // return lowest value, so NA sorts below everything else.
+ return -Number.MAX_VALUE;
+ }
+ var numClean = x.replace(/[$,]/g, ''); // remove dollar signs and commas
+ return parseFloat(numClean);
+}
+
+// as a date.
+//
+// TODO: Parse into JS date object?
+// http://stackoverflow.com/questions/19430561/how-to-sort-a-javascript-array-of-objects-by-date
+// Uses getTime(). Hm.
+
+function asDate(x) {
+ return x;
+}
+
+//
+// Table Implementation
+//
+
+// Given a column array and a key function, construct a permutation of the
+// indices [0, n).
+function makePermutation(colArray, keyFunc) {
+ var pairs = []; // (index, result of keyFunc on cell)
+
+ var n = colArray.length;
+ for (var i = 0; i < n; ++i) {
+ var value = colArray[i];
+
+ // NOTE: This could be a URL, so you need to extract that?
+ // If it's a URL, take the anchor text I guess.
+ var key = keyFunc(value);
+
+ pairs.push([key, i]);
+ }
+
+ // Sort by computed key
+ pairs.sort(function(a, b) {
+ if (a[0] < b[0]) {
+ return -1;
+ } else if (a[0] > b[0]) {
+ return 1;
+ } else {
+ return 0;
+ }
+ });
+
+ // Extract the permutation as second column
+ var perm = [];
+ for (var i = 0; i < pairs.length; ++i) {
+ perm.push(pairs[i][1]); // append index
+ }
+ return perm;
+}
+
+function extractCol(rows, colIndex) {
+ var colArray = [];
+ for (var i = 0; i < rows.length; ++i) {
+ var row = rows[i];
+ colArray.push(row.cells[colIndex].textContent);
+ }
+ return colArray;
+}
+
+// Given an array of DOM row objects, and a list of sort functions (one per
+// column), return a list of permutations.
+//
+// Right now this is eager. Could be lazy later.
+function makeAllPermutations(rows, keyFuncs) {
+ var numCols = keyFuncs.length;
+ var permutations = [];
+ for (var i = 0; i < numCols; ++i) {
+ var colArray = extractCol(rows, i);
+ var keyFunc = keyFuncs[i];
+ var p = makePermutation(colArray, keyFunc);
+ permutations.push(p);
+ }
+ return permutations;
+}
+
+// Model object for a table. (Mostly) independent of the DOM.
+function TableState(table, keyFuncs) {
+ this.table = table;
+ keyFuncs = keyFuncs || []; // array of column
+
+ // these are mutated
+ this.sortCol = -1; // not sorted by any col
+ this.ascending = false; // if sortCol is sorted in ascending order
+
+ if (table === null) { // hack so we can pass dummy table
+ console.log('TESTING');
+ return;
+ }
+
+ var bodyRows = table.tBodies[0].rows;
+ this.orig = []; // pointers to row objects in their original order
+ for (var i = 0; i < bodyRows.length; ++i) {
+ this.orig.push(bodyRows[i]);
+ }
+
+ this.colElems = [];
+ var colgroup = table.getElementsByTagName('colgroup')[0];
+
+ // copy it into an array
+ if (!colgroup) {
+ throw new Error('<colgroup> is required');
+ }
+
+ for (var i = 0; i < colgroup.children.length; ++i) {
+ var colElem = colgroup.children[i];
+ var colType = colElem.getAttribute('type');
+ var keyFunc;
+ switch (colType) {
+ case 'case-sensitive':
+ keyFunc = identity;
+ break;
+ case 'case-insensitive':
+ keyFunc = lowerCase;
+ break;
+ case 'number':
+ keyFunc = asNumber;
+ break;
+ case 'date':
+ keyFunc = asDate;
+ break;
+ default:
+ throw new Error('Invalid column type ' + colType);
+ }
+ keyFuncs[i] = keyFunc;
+
+ this.colElems.push(colElem);
+ }
+
+ this.permutations = makeAllPermutations(this.orig, keyFuncs);
+}
+
+// Reset sort state.
+TableState.prototype.resetSort = function() {
+ this.sortCol = -1; // not sorted by any col
+ this.ascending = false; // if sortCol is sorted in ascending order
+};
+
+// Change state for a click on a column.
+TableState.prototype.doClick = function(colIndex) {
+ if (this.sortCol === colIndex) { // same column; invert direction
+ this.ascending = !this.ascending;
+ } else { // different column
+ this.sortCol = colIndex;
+ // first click makes it *descending*. Typically you want to see the
+ // largest values first.
+ this.ascending = false;
+ }
+};
+
+TableState.prototype.decode = function(stateStr, errElem) {
+ var sortCol = parseInt(stateStr); // parse leading integer
+ var lastChar = stateStr[stateStr.length - 1];
+
+ var ascending;
+ if (lastChar === 'a') {
+ ascending = true;
+ } else if (lastChar === 'd') {
+ ascending = false;
+ } else {
+ // The user could have entered a bad ID
+ userError(errElem, 'Invalid state string ' + stateStr);
+ return;
+ }
+
+ this.sortCol = sortCol;
+ this.ascending = ascending;
+}
+
+
+TableState.prototype.encode = function() {
+ if (this.sortCol === -1) {
+ return ''; // default state isn't serialized
+ }
+
+ var s = this.sortCol.toString();
+ s += this.ascending ? 'a' : 'd';
+ return s;
+};
+
+// Update the DOM with using this object's internal state.
+TableState.prototype.updateDom = function() {
+ var tHead = this.table.tHead;
+ setArrows(tHead, this.sortCol, this.ascending);
+
+ // Highlight the column that the table is sorted by.
+ for (var i = 0; i < this.colElems.length; ++i) {
+ // set or clear it. NOTE: This means we can't have other classes on the
+ // <col> tags, which is OK.
+ var className = (i === this.sortCol) ? 'highlight' : '';
+ this.colElems[i].className = className;
+ }
+
+ var n = this.orig.length;
+ var tbody = this.table.tBodies[0];
+
+ if (this.sortCol === -1) { // reset it and return
+ for (var i = 0; i < n; ++i) {
+ tbody.appendChild(this.orig[i]);
+ }
+ return;
+ }
+
+ var perm = this.permutations[this.sortCol];
+ if (this.ascending) {
+ for (var i = 0; i < n; ++i) {
+ var index = perm[i];
+ tbody.appendChild(this.orig[index]);
+ }
+ } else { // descending, apply the permutation in reverse order
+ for (var i = n - 1; i >= 0; --i) {
+ var index = perm[i];
+ tbody.appendChild(this.orig[index]);
+ }
+ }
+};
+
+var kTablePrefix = 't:';
+var kTablePrefixLength = 2;
+
+// Given a UrlHash instance and a list of tables, mutate tableStates.
+function decodeState(urlHash, tableStates, errElem) {
+ var keys = urlHash.getKeysWithPrefix(kTablePrefix); // by convention, t:foo=1a
+ for (var i = 0; i < keys.length; ++i) {
+ var key = keys[i];
+ var tableId = key.substring(kTablePrefixLength);
+
+ if (!tableStates.hasOwnProperty(tableId)) {
+ // The user could have entered a bad ID
+ userError(errElem, 'Invalid table ID [' + tableId + ']');
+ return;
+ }
+
+ var state = tableStates[tableId];
+ var stateStr = urlHash.get(key); // e.g. '1d'
+
+ state.decode(stateStr, errElem);
+ }
+}
+
+// Add <span> element for sort arrows.
+function addArrowSpans(tHead) {
+ var tHeadCells = tHead.rows[0].cells;
+ for (var i = 0; i < tHeadCells.length; ++i) {
+ var colHead = tHeadCells[i];
+ // Put a space in so the width is relatively constant
+ colHead.innerHTML += ' <span class="sortArrow">&nbsp;</span>';
+ }
+}
+
+// Go through all the cells in the header. Clear the arrow if there is one.
+// Set the one on the correct column.
+//
+// How to do this? Each column needs a <span></span> modify the text?
+function setArrows(tHead, sortCol, ascending) {
+ var tHeadCells = tHead.rows[0].cells;
+
+ for (var i = 0; i < tHeadCells.length; ++i) {
+ var colHead = tHeadCells[i];
+ var span = colHead.getElementsByTagName('span')[0];
+
+ if (i === sortCol) {
+ span.innerHTML = ascending ? '&#x25B4;' : '&#x25BE;';
+ } else {
+ span.innerHTML = '&nbsp;'; // clear it
+ }
+ }
+}
+
+// Given the URL hash, table states, tableId, and column index that was
+// clicked, visit a new location.
+function makeClickHandler(urlHash, tableStates, id, colIndex) {
+ return function() { // no args for onclick=
+ var clickedState = tableStates[id];
+
+ clickedState.doClick(colIndex);
+
+ // now urlHash has non-table state, and tableStates is the table state.
+ for (var tableId in tableStates) {
+ var state = tableStates[tableId];
+
+ var stateStr = state.encode();
+ var key = kTablePrefix + tableId;
+
+ if (stateStr === '') {
+ urlHash.del(key);
+ } else {
+ urlHash.set(key, stateStr);
+ }
+ }
+
+ // move to new location
+ location.hash = urlHash.encode();
+ };
+}
+
+// Go through cells and register onClick
+function registerClick(table, urlHash, tableStates) {
+ var id = table.id; // id is required
+
+ var tHeadCells = table.tHead.rows[0].cells;
+ for (var colIndex = 0; colIndex < tHeadCells.length; ++colIndex) {
+ var colHead = tHeadCells[colIndex];
+ // NOTE: in ES5, could use 'bind'.
+ colHead.onclick = makeClickHandler(urlHash, tableStates, id, colIndex);
+ }
+}
+
+//
+// Public Functions (TODO: Make a module?)
+//
+
+// Parse the URL fragment, and update all tables. Errors are printed to a DOM
+// element.
+function updateTables(urlHash, tableStates, statusElem) {
+ // State should come from the hash alone, so reset old state. (We want to
+ // keep the permutations though.)
+ for (var tableId in tableStates) {
+ tableStates[tableId].resetSort();
+ }
+
+ decodeState(urlHash, tableStates, statusElem);
+
+ for (var name in tableStates) {
+ var state = tableStates[name];
+ state.updateDom();
+ }
+}
+
+// Takes a {tableId: spec} object. The spec should be an array of sortable
+// items.
+// Returns a dictionary of table states.
+function makeTablesSortable(urlHash, tables, tableStates) {
+ for (var i = 0; i < tables.length; ++i) {
+ var table = tables[i];
+ var tableId = table.id;
+
+ registerClick(table, urlHash, tableStates);
+ tableStates[tableId] = new TableState(table);
+
+ addArrowSpans(table.tHead);
+ }
+ return tableStates;
+}
+
+// table-sort.js can use t:holidays=1d
+//
+// metric.html can use:
+//
+// metric=Foo.bar
+//
+// day.html could use
+//
+// jobId=X&metric=Foo.bar&day=2015-06-01
+
+// helper
+function _decode(s) {
+ var obj = {};
+ var parts = s.split('&');
+ for (var i = 0; i < parts.length; ++i) {
+ if (parts[i].length === 0) {
+ continue; // quirk: ''.split('&') is [''] ? Should be a 0-length array.
+ }
+ var pair = parts[i].split('=');
+ obj[pair[0]] = pair[1]; // for now, assuming no =
+ }
+ return obj;
+}
+
+// UrlHash Constructor.
+// Args:
+// hashStr: location.hash
+function UrlHash(hashStr) {
+ this.reset(hashStr);
+}
+
+UrlHash.prototype.reset = function(hashStr) {
+ var h = hashStr.substring(1); // without leading #
+ // Internal storage is string -> string
+ this.dict = _decode(h);
+}
+
+UrlHash.prototype.set = function(name, value) {
+ this.dict[name] = value;
+};
+
+UrlHash.prototype.del = function(name) {
+ delete this.dict[name];
+};
+
+UrlHash.prototype.get = function(name ) {
+ return this.dict[name];
+};
+
+// e.g. Table states have keys which start with 't:'.
+UrlHash.prototype.getKeysWithPrefix = function(prefix) {
+ var keys = [];
+ for (var name in this.dict) {
+ if (name.indexOf(prefix) === 0) {
+ keys.push(name);
+ }
+ }
+ return keys;
+};
+
+// Return a string reflecting internal key-value pairs.
+UrlHash.prototype.encode = function() {
+ var parts = [];
+ for (var name in this.dict) {
+ var s = name;
+ s += '=';
+ var value = this.dict[name];
+ s += encodeURIComponent(value);
+ parts.push(s);
+ }
+ return parts.join('&');
+};
diff --git a/ui/table-sort.css b/ui/table-sort.css
new file mode 100644
index 0000000..1034f4e
--- /dev/null
+++ b/ui/table-sort.css
@@ -0,0 +1,39 @@
+/* sort indicator in column headings */
+.sortArrow {
+ color: grey;
+}
+
+thead {
+ font-weight: bold;
+ text-align: center;
+}
+
+table {
+ padding: 10px; /* Padding makes it look nicer. */
+ margin: 0 auto; /* center table on the page */
+ border-collapse: collapse; /* this is like old cellpadding */
+}
+
+/* like cellspacing? */
+td {
+ padding: 5px;
+}
+
+/* Built-in support for R NA values */
+.na {
+ color: darkred;
+}
+
+/* Numbers aligned on the right, like Excel */
+.num {
+ text-align: right;
+}
+
+.highlight {
+ background-color: #f0f0f0;
+}
+
+tbody tr:hover {
+ background-color: lightcyan;
+}
+
diff --git a/ui/ui.css b/ui/ui.css
new file mode 100644
index 0000000..8431ecf
--- /dev/null
+++ b/ui/ui.css
@@ -0,0 +1,53 @@
+/* Center the plots */
+.dy {
+ margin: 0 auto;
+ width: 50em;
+}
+
+/* main metric */
+#proportionsDy {
+ width: 1000px;
+ height: 600px;
+}
+
+#num-reports-dy {
+ width: 1000px;
+ height: 300px;
+}
+
+#mass-dy {
+ width: 1000px;
+ height: 300px;
+}
+
+#metricDesc {
+ font-style: italic;
+}
+
+body {
+ /*margin: 0 auto;*/
+ /*text-align: left;*/
+}
+
+h1 {
+ text-align: center;
+}
+
+h2 {
+ text-align: center;
+}
+
+p {
+ text-align: center;
+}
+
+/* R NA values */
+.na {
+ color: darkred;
+}
+
+#status {
+ text-align: center;
+ font-size: x-large;
+ color: darkred;
+}
diff --git a/ui/ui.js b/ui/ui.js
new file mode 100644
index 0000000..b74a8e2
--- /dev/null
+++ b/ui/ui.js
@@ -0,0 +1,363 @@
+// Dashboard UI functions.
+//
+// This is shared between all HTML pages.
+
+'use strict';
+
+// Append a message to an element. Used for errors.
+function appendMessage(elem, msg) {
+ elem.innerHTML += msg + '<br />';
+}
+
+// jQuery-like AJAX helper, but simpler.
+
+// Requires an element with id "status" to show errors.
+//
+// Args:
+// errElem: optional element to append error messages to. If null, then
+// alert() on error.
+// success: callback that is passed the xhr object.
+function ajaxGet(url, errElem, success) {
+ var xhr = new XMLHttpRequest();
+ xhr.open('GET', url, true /*async*/);
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState != 4 /*DONE*/) {
+ return;
+ }
+
+ if (xhr.status != 200) {
+ var msg = 'ERROR requesting ' + url + ': ' + xhr.status + ' ' +
+ xhr.statusText;
+ if (errElem) {
+ appendMessage(errElem, msg);
+ } else {
+ alert(msg);
+ }
+ return;
+ }
+
+ success(xhr);
+ };
+ xhr.send();
+}
+
+// Load metadata about the metrics.
+// metric-metadata.json is just 14 KB, so we load it for every page.
+//
+// callback:
+// on metric page, just pick out the right description.
+// on overview page, populate them ALL with tool tips?
+// Or create another column?
+function loadMetricMetadata(errElem, success) {
+ // TODO: Should we make metric-metadata.json optional? Some may not have it.
+
+ ajaxGet('metric-metadata.json', errElem, function(xhr) {
+ // TODO: handle parse error
+ var m = JSON.parse(xhr.responseText);
+ success(m);
+ });
+}
+
+// for overview.html.
+function initOverview(urlHash, tableStates, statusElem) {
+
+ ajaxGet('cooked/overview.part.html', statusElem, function(xhr) {
+ var elem = document.getElementById('overview');
+ elem.innerHTML = xhr.responseText;
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+
+ loadMetricMetadata(statusElem, function(metadata) {
+ var elem = document.getElementById('metricMetadata').tBodies[0];
+ var metrics = metadata.metrics;
+
+ // Sort by the metric name
+ var metricNames = Object.getOwnPropertyNames(metrics);
+ metricNames.sort();
+
+ var tableHtml = '';
+ for (var i = 0; i < metricNames.length; ++i) {
+ var name = metricNames[i];
+ var meta = metrics[name];
+ tableHtml += '<tr>';
+ tableHtml += '<td>' + name + '</td>';
+ tableHtml += '<td>' + meta.owners + '</td>';
+ tableHtml += '<td>' + meta.summary + '</td>';
+ tableHtml += '</tr>';
+ }
+ elem.innerHTML += tableHtml;
+ });
+}
+
+// for metric.html.
+function initMetric(urlHash, tableStates, statusElem, globals) {
+
+ var metricName = urlHash.get('metric');
+ if (metricName === undefined) {
+ appendMessage(statusElem, "Missing metric name in URL hash.");
+ return;
+ }
+
+ loadMetricMetadata(statusElem, function(metadata) {
+ var meta = metadata.metrics[metricName];
+ if (!meta) {
+ appendMessage(statusElem, 'Found no metadata for ' + metricName);
+ return;
+ }
+ var descElem = document.getElementById('metricDesc');
+ descElem.innerHTML = meta.summary;
+
+ // TODO: put owners at the bottom of the page somewhere?
+ });
+
+ // Add title and page element
+ document.title = metricName;
+ var nameElem = document.getElementById('metricName');
+ nameElem.innerHTML = metricName;
+
+ // Add correct links.
+ var u = document.getElementById('underlying-status');
+ u.href = 'cooked/' + metricName + '/status.csv';
+
+ var distUrl = 'cooked/' + metricName + '/dist.csv';
+ var u2 = document.getElementById('underlying-dist');
+ u2.href = distUrl;
+
+ ajaxGet(distUrl, statusElem, function(xhr) {
+ var csvData = xhr.responseText;
+ var elem = document.getElementById('proportionsDy');
+ // Mutate global so we can respond to onclick.
+ globals.proportionsDygraph = new Dygraph(elem, csvData, {customBars: true});
+ });
+
+ var numReportsUrl = 'cooked/' + metricName + '/num_reports.csv';
+ ajaxGet(numReportsUrl, statusElem, function(xhr) {
+ var csvData = xhr.responseText;
+ var elem = document.getElementById('num-reports-dy');
+ var g = new Dygraph(elem, csvData);
+ });
+
+ var massUrl = 'cooked/' + metricName + '/mass.csv';
+ ajaxGet(massUrl, statusElem, function(xhr) {
+ var csvData = xhr.responseText;
+ var elem = document.getElementById('mass-dy');
+ var g = new Dygraph(elem, csvData);
+ });
+
+ var tableUrl = 'cooked/' + metricName + '/status.part.html';
+ ajaxGet(tableUrl, statusElem, function(xhr) {
+ var htmlData = xhr.responseText;
+ var elem = document.getElementById('status_table');
+ elem.innerHTML = htmlData;
+
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+}
+
+// NOTE: This was for optional Dygraphs error bars, but it's not hooked up yet.
+function onMetricCheckboxClick(checkboxElem, proportionsDygraph) {
+ var checked = checkboxElem.checked;
+ if (proportionsDygraph === null) {
+ console.log('NULL');
+ }
+ proportionsDygraph.updateOptions({customBars: checked});
+ console.log('HANDLED');
+}
+
+// for day.html.
+function initDay(urlHash, tableStates, statusElem) {
+ var jobId = urlHash.get('jobId');
+ var metricName = urlHash.get('metric');
+ var date = urlHash.get('date');
+
+ var err = '';
+ if (!jobId) {
+ err = 'jobId missing from hash';
+ }
+ if (!metricName) {
+ err = 'metric missing from hash';
+ }
+ if (!date) {
+ err = 'date missing from hash';
+ }
+ if (err) {
+ appendMessage(statusElem, err);
+ }
+
+ // Add title and page element
+ var titleStr = metricName + ' on ' + date;
+ document.title = titleStr;
+ var mElem = document.getElementById('metricDay');
+ mElem.innerHTML = titleStr;
+
+ // Add correct links.
+ var u = document.getElementById('underlying');
+ u.href = '../' + jobId + '/raw/' + metricName + '/' + date +
+ '/results.csv';
+
+ // Add correct links.
+ var u_res = document.getElementById('residual');
+ u_res.src = '../' + jobId + '/raw/' + metricName + '/' + date +
+ '/residual.png';
+
+ var url = '../' + jobId + '/cooked/' + metricName + '/' + date + '.part.html';
+ ajaxGet(url, statusElem, function(xhr) {
+ var htmlData = xhr.responseText;
+ var elem = document.getElementById('results_table');
+ elem.innerHTML = htmlData;
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+}
+
+// for assoc-overview.html.
+function initAssocOverview(urlHash, tableStates, statusElem) {
+ ajaxGet('cooked/assoc-overview.part.html', statusElem, function(xhr) {
+ var elem = document.getElementById('overview');
+ elem.innerHTML = xhr.responseText;
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+}
+
+// for assoc-metric.html.
+function initAssocMetric(urlHash, tableStates, statusElem) {
+ var metricName = urlHash.get('metric');
+ if (metricName === undefined) {
+ appendMessage(statusElem, "Missing metric name in URL hash.");
+ return;
+ }
+
+ // Add title and page element
+ var title = metricName + ': pairs of variables';
+ document.title = title;
+ var pageTitleElem = document.getElementById('pageTitle');
+ pageTitleElem.innerHTML = title;
+
+ // Add correct links.
+ var u = document.getElementById('underlying-status');
+ u.href = 'cooked/' + metricName + '/metric-status.csv';
+
+ var csvPath = 'cooked/' + metricName + '/metric-status.part.html';
+ ajaxGet(csvPath, statusElem, function(xhr) {
+ var elem = document.getElementById('metric_table');
+ elem.innerHTML = xhr.responseText;
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+}
+
+// Function to help us find the *.part.html files.
+//
+// NOTE: This naming convention matches the one defined in task_spec.py
+// AssocTaskSpec.
+function formatAssocRelPath(metricName, var1, var2) {
+ var varDir = var1 + '_X_' + var2.replace('..', '_');
+ return metricName + '/' + varDir;
+}
+
+// for assoc-pair.html
+function initAssocPair(urlHash, tableStates, statusElem, globals) {
+
+ var metricName = urlHash.get('metric');
+ if (metricName === undefined) {
+ appendMessage(statusElem, "Missing metric name in URL hash.");
+ return;
+ }
+ var var1 = urlHash.get('var1');
+ if (var1 === undefined) {
+ appendMessage(statusElem, "Missing var1 in URL hash.");
+ return;
+ }
+ var var2 = urlHash.get('var2');
+ if (var2 === undefined) {
+ appendMessage(statusElem, "Missing var2 in URL hash.");
+ return;
+ }
+
+ var relPath = formatAssocRelPath(metricName, var1, var2);
+
+ // Add title and page element
+ var title = metricName + ': ' + var1 + ' vs. ' + var2;
+ document.title = title;
+ var pageTitleElem = document.getElementById('pageTitle');
+ pageTitleElem.innerHTML = title;
+
+ // Add correct links.
+ var u = document.getElementById('underlying-status');
+ u.href = 'cooked/' + relPath + '/pair-status.csv';
+
+ /*
+ var distUrl = 'cooked/' + metricName + '/dist.csv';
+ var u2 = document.getElementById('underlying-dist');
+ u2.href = distUrl;
+ */
+
+ var tableUrl = 'cooked/' + relPath + '/pair-status.part.html';
+ ajaxGet(tableUrl, statusElem, function(xhr) {
+ var htmlData = xhr.responseText;
+ var elem = document.getElementById('status_table');
+ elem.innerHTML = htmlData;
+
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+}
+
+// for assoc-day.html.
+function initAssocDay(urlHash, tableStates, statusElem) {
+ var jobId = urlHash.get('jobId');
+ var metricName = urlHash.get('metric');
+ var var1 = urlHash.get('var1');
+ var var2 = urlHash.get('var2');
+ var date = urlHash.get('date');
+
+ var err = '';
+ if (!jobId) {
+ err = 'jobId missing from hash';
+ }
+ if (!metricName) {
+ err = 'metric missing from hash';
+ }
+ if (!var1) {
+ err = 'var1 missing from hash';
+ }
+ if (!var2) {
+ err = 'var2 missing from hash';
+ }
+ if (!date) {
+ err = 'date missing from hash';
+ }
+ if (err) {
+ appendMessage(statusElem, err);
+ }
+
+ // Add title and page element
+ var titleStr = metricName + ': ' + var1 + ' vs. ' + var2 + ' on ' + date;
+ document.title = titleStr;
+ var mElem = document.getElementById('metricDay');
+ mElem.innerHTML = titleStr;
+
+ var relPath = formatAssocRelPath(metricName, var1, var2);
+
+ // Add correct links.
+ var u = document.getElementById('underlying');
+ u.href = '../' + jobId + '/raw/' + relPath + '/' + date +
+ '/assoc-results.csv';
+
+ var url = '../' + jobId + '/cooked/' + relPath + '/' + date + '.part.html';
+ ajaxGet(url, statusElem, function(xhr) {
+ var htmlData = xhr.responseText;
+ var elem = document.getElementById('results_table');
+ elem.innerHTML = htmlData;
+ makeTablesSortable(urlHash, [elem], tableStates);
+ updateTables(urlHash, tableStates, statusElem);
+ });
+}
+
+// This is the onhashchange handler of *all* HTML files.
+function onHashChange(urlHash, tableStates, statusElem) {
+ updateTables(urlHash, tableStates, statusElem);
+}