diff options
Diffstat (limited to 'ui')
-rw-r--r-- | ui/README.md | 6 | ||||
-rw-r--r-- | ui/assoc-day.html | 44 | ||||
-rw-r--r-- | ui/assoc-metric.html | 45 | ||||
-rw-r--r-- | ui/assoc-overview.html | 43 | ||||
-rw-r--r-- | ui/assoc-pair.html | 47 | ||||
-rw-r--r-- | ui/day.html | 49 | ||||
-rw-r--r-- | ui/histograms.html | 48 | ||||
-rw-r--r-- | ui/home.html | 16 | ||||
-rw-r--r-- | ui/metric.html | 83 | ||||
-rw-r--r-- | ui/overview.html | 59 | ||||
-rw-r--r-- | ui/table-lib.js | 482 | ||||
-rw-r--r-- | ui/table-sort.css | 39 | ||||
-rw-r--r-- | ui/ui.css | 53 | ||||
-rw-r--r-- | ui/ui.js | 363 |
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"> </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 ? '▴' : '▾'; + } else { + span.innerHTML = ' '; // 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); +} |