| /* |
| * Copyright 2014 Open Networking Laboratory |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| /* |
| ONOS GUI -- Base Framework |
| |
| @author Simon Hunt |
| */ |
| |
| (function ($) { |
| 'use strict'; |
| var tsI = new Date().getTime(), // initialize time stamp |
| tsB, // build time stamp |
| mastHeight = 36, // see mast2.css |
| defaultVid = 'sample'; |
| |
| |
| // attach our main function to the jQuery object |
| $.onos = function (options) { |
| var uiApi, |
| viewApi, |
| navApi, |
| libApi, |
| exported = {}; |
| |
| var defaultOptions = { |
| trace: false, |
| theme: 'light', |
| startVid: defaultVid |
| }; |
| |
| // compute runtime settings |
| var settings = $.extend({}, defaultOptions, options); |
| |
| // set the selected theme |
| d3.select('body').classed(settings.theme, true); |
| |
| // internal state |
| var views = {}, |
| fpanels = {}, |
| current = { |
| view: null, |
| ctx: '', |
| flags: {}, |
| theme: settings.theme |
| }, |
| built = false, |
| buildErrors = [], |
| keyHandler = { |
| globalKeys: {}, |
| maskedKeys: {}, |
| viewKeys: {}, |
| viewFn: null |
| }, |
| alerts = { |
| open: false, |
| count: 0 |
| }; |
| |
| // DOM elements etc. |
| // TODO: verify existence of following elements... |
| var $view = d3.select('#view'), |
| $floatPanels = d3.select('#floatPanels'), |
| $alerts = d3.select('#alerts'), |
| // note, following elements added programmatically... |
| $mastRadio; |
| |
| |
| function whatKey(code) { |
| switch (code) { |
| case 13: return 'enter'; |
| case 16: return 'shift'; |
| case 17: return 'ctrl'; |
| case 18: return 'alt'; |
| case 27: return 'esc'; |
| case 32: return 'space'; |
| case 37: return 'leftArrow'; |
| case 38: return 'upArrow'; |
| case 39: return 'rightArrow'; |
| case 40: return 'downArrow'; |
| case 91: return 'cmdLeft'; |
| case 93: return 'cmdRight'; |
| default: |
| if ((code >= 48 && code <= 57) || |
| (code >= 65 && code <= 90)) { |
| return String.fromCharCode(code); |
| } else if (code >= 112 && code <= 123) { |
| return 'F' + (code - 111); |
| } |
| return '.'; |
| } |
| } |
| |
| |
| // .......................................................... |
| // Internal functions |
| |
| // throw an error |
| function throwError(msg) { |
| // separate function, as we might add tracing here too, later |
| throw new Error(msg); |
| } |
| |
| function doError(msg) { |
| console.error(msg); |
| doAlert(msg); |
| } |
| |
| function trace(msg) { |
| if (settings.trace) { |
| console.log(msg); |
| } |
| } |
| |
| function traceFn(fn, params) { |
| if (settings.trace) { |
| console.log('*FN* ' + fn + '(...): ' + params); |
| } |
| } |
| |
| // hash navigation |
| function hash() { |
| var hash = window.location.hash, |
| redo = false, |
| view, |
| t; |
| |
| traceFn('hash', hash); |
| |
| if (!hash) { |
| hash = settings.startVid; |
| redo = true; |
| } |
| |
| t = parseHash(hash); |
| if (!t || !t.vid) { |
| doError('Unable to parse target hash: "' + hash + '"'); |
| } |
| |
| view = views[t.vid]; |
| if (!view) { |
| doError('No view defined with id: ' + t.vid); |
| } |
| |
| if (redo) { |
| window.location.hash = makeHash(t); |
| // the above will result in a hashchange event, invoking |
| // this function again |
| } else { |
| // hash was not modified... navigate to where we need to be |
| navigate(hash, view, t); |
| } |
| } |
| |
| function parseHash(s) { |
| // extract navigation coordinates from the supplied string |
| // "vid,ctx?flag1,flag2" --> { vid:vid, ctx:ctx, flags:{...} } |
| traceFn('parseHash', s); |
| |
| // look for use of flags, first |
| var vidctx, |
| vid, |
| ctx, |
| flags, |
| flagMap, |
| m; |
| |
| // RE that includes flags ('?flag1,flag2') |
| m = /^[#]{0,1}(.+)\?(.+)$/.exec(s); |
| if (m) { |
| vidctx = m[1]; |
| flags = m[2]; |
| flagMap = {}; |
| } else { |
| // no flags |
| m = /^[#]{0,1}((.+)(,.+)*)$/.exec(s); |
| if (m) { |
| vidctx = m[1]; |
| } else { |
| // bad hash |
| return null; |
| } |
| } |
| |
| vidctx = vidctx.split(','); |
| vid = vidctx[0]; |
| ctx = vidctx[1]; |
| if (flags) { |
| flags.split(',').forEach(function (f) { |
| flagMap[f.trim()] = true; |
| }); |
| } |
| |
| return { |
| vid: vid.trim(), |
| ctx: ctx ? ctx.trim() : '', |
| flags: flagMap |
| }; |
| |
| } |
| |
| function makeHash(t, ctx, flags) { |
| traceFn('makeHash'); |
| // make a hash string from the given navigation coordinates, |
| // and optional flags map. |
| // if t is not an object, then it is a vid |
| var h = t, |
| c = ctx || '', |
| f = $.isPlainObject(flags) ? flags : null; |
| |
| if ($.isPlainObject(t)) { |
| h = t.vid; |
| c = t.ctx || ''; |
| f = t.flags || null; |
| } |
| |
| if (c) { |
| h += ',' + c; |
| } |
| if (f) { |
| h += '?' + d3.map(f).keys().join(','); |
| } |
| trace('hash = "' + h + '"'); |
| return h; |
| } |
| |
| function navigate(hash, view, t) { |
| traceFn('navigate', view.vid); |
| // closePanes() // flyouts etc. |
| // updateNav() // accordion / selected nav item etc. |
| createView(view); |
| setView(view, hash, t); |
| } |
| |
| function buildError(msg) { |
| buildErrors.push(msg); |
| } |
| |
| function reportBuildErrors() { |
| traceFn('reportBuildErrors'); |
| var nerr = buildErrors.length, |
| errmsg; |
| if (!nerr) { |
| console.log('(no build errors)'); |
| } else { |
| errmsg = 'Build errors: ' + nerr + ' found...\n\n' + |
| buildErrors.join('\n'); |
| doAlert(errmsg); |
| console.error(errmsg); |
| } |
| } |
| |
| // returns the reference if it is a function, null otherwise |
| function isF(f) { |
| return $.isFunction(f) ? f : null; |
| } |
| |
| // .......................................................... |
| // View life-cycle functions |
| |
| function setViewDimensions(sel) { |
| var w = window.innerWidth, |
| h = window.innerHeight - mastHeight; |
| sel.each(function () { |
| $(this) |
| .css('width', w + 'px') |
| .css('height', h + 'px') |
| }); |
| } |
| |
| function createView(view) { |
| var $d; |
| |
| // lazy initialization of the view |
| if (view && !view.$div) { |
| trace('creating view for ' + view.vid); |
| $d = $view.append('div') |
| .attr({ |
| id: view.vid, |
| class: 'onosView' |
| }); |
| setViewDimensions($d); |
| view.$div = $d; // cache a reference to the D3 selection |
| } |
| } |
| |
| function setView(view, hash, t) { |
| traceFn('setView', view.vid); |
| // set the specified view as current, while invoking the |
| // appropriate life-cycle callbacks |
| |
| // first, we'll start by closing the alerts pane, if open |
| closeAlerts(); |
| |
| // if there is a current view, and it is not the same as |
| // the incoming view, then unload it... |
| if (current.view && (current.view.vid !== view.vid)) { |
| current.view.unload(); |
| |
| // detach radio buttons, key handlers, etc. |
| $('#mastRadio').children().detach(); |
| keyHandler.viewKeys = {}; |
| keyHandler.viewFn = null; |
| } |
| |
| // cache new view and context |
| current.view = view; |
| current.ctx = t.ctx || ''; |
| current.flags = t.flags || {}; |
| |
| // preload is called only once, after the view is in the DOM |
| if (!view.preloaded) { |
| view.preload(current.ctx, current.flags); |
| view.preloaded = true; |
| } |
| |
| // clear the view of stale data |
| view.reset(); |
| |
| // load the view |
| view.load(current.ctx, current.flags); |
| } |
| |
| // generate 'unique' id by prefixing view id |
| function makeUid(view, id) { |
| return view.vid + '-' + id; |
| } |
| |
| // restore id by removing view id prefix |
| function unmakeUid(view, uid) { |
| var re = new RegExp('^' + view.vid + '-'); |
| return uid.replace(re, ''); |
| } |
| |
| function setRadioButtons(vid, btnSet) { |
| var view = views[vid], |
| btnG, |
| api = {}; |
| |
| // lazily create the buttons... |
| if (!(btnG = view.radioButtons)) { |
| btnG = d3.select(document.createElement('div')); |
| btnG.buttonDef = {}; |
| |
| btnSet.forEach(function (btn, i) { |
| var bid = btn.id || 'b' + i, |
| txt = btn.text || 'Button #' + i, |
| uid = makeUid(view, bid), |
| button = btnG.append('span') |
| .attr({ |
| id: uid, |
| class: 'radio' |
| }) |
| .text(txt); |
| |
| btn.id = bid; |
| btnG.buttonDef[uid] = btn; |
| |
| if (i === 0) { |
| button.classed('active', true); |
| btnG.selected = bid; |
| } |
| }); |
| |
| btnG.selectAll('span') |
| .on('click', function (d) { |
| var button = d3.select(this), |
| uid = button.attr('id'), |
| btn = btnG.buttonDef[uid], |
| act = button.classed('active'); |
| |
| if (!act) { |
| btnG.selectAll('span').classed('active', false); |
| button.classed('active', true); |
| btnG.selected = btn.id; |
| if (isF(btn.cb)) { |
| btn.cb(view.token(), btn); |
| } |
| } |
| }); |
| |
| view.radioButtons = btnG; |
| |
| api.selected = function () { |
| return btnG.selected; |
| } |
| } |
| |
| // attach the buttons to the masthead |
| $mastRadio.node().appendChild(btnG.node()); |
| // return an api for interacting with the button set |
| return api; |
| } |
| |
| function setupGlobalKeys() { |
| keyHandler.globalKeys = { |
| esc: escapeKey, |
| T: toggleTheme |
| }; |
| // Masked keys are global key handlers that always return true. |
| // That is, the view will never see the event for that key. |
| keyHandler.maskedKeys = { |
| T: true |
| }; |
| } |
| |
| function escapeKey(view, key, code, ev) { |
| if (alerts.open) { |
| closeAlerts(); |
| return true; |
| } |
| return false; |
| } |
| |
| function toggleTheme(view, key, code, ev) { |
| var body = d3.select('body'); |
| current.theme = (current.theme === 'light') ? 'dark' : 'light'; |
| body.classed('light dark', false); |
| body.classed(current.theme, true); |
| return true; |
| } |
| |
| function setKeyBindings(keyArg) { |
| var viewKeys, |
| masked = []; |
| |
| if ($.isFunction(keyArg)) { |
| // set general key handler callback |
| keyHandler.viewFn = keyArg; |
| } else { |
| // set specific key filter map |
| viewKeys = d3.map(keyArg).keys(); |
| viewKeys.forEach(function (key) { |
| if (keyHandler.maskedKeys[key]) { |
| masked.push(' Key "' + key + '" is reserved'); |
| } |
| }); |
| |
| if (masked.length) { |
| doAlert('WARNING...\n\nsetKeys():\n' + masked.join('\n')); |
| } |
| keyHandler.viewKeys = keyArg; |
| } |
| } |
| |
| function keyIn() { |
| var event = d3.event, |
| keyCode = event.keyCode, |
| key = whatKey(keyCode), |
| gcb = isF(keyHandler.globalKeys[key]), |
| vcb = isF(keyHandler.viewKeys[key]) || isF(keyHandler.viewFn); |
| |
| // global callback? |
| if (gcb && gcb(current.view.token(), key, keyCode, event)) { |
| // if the event was 'handled', we are done |
| return; |
| } |
| // otherwise, let the view callback have a shot |
| if (vcb) { |
| vcb(current.view.token(), key, keyCode, event); |
| } |
| } |
| |
| function createAlerts() { |
| $alerts.style('display', 'block'); |
| $alerts.append('span') |
| .attr('class', 'close') |
| .text('X') |
| .on('click', closeAlerts); |
| $alerts.append('pre'); |
| $alerts.append('p').attr('class', 'footnote') |
| .text('Press ESCAPE to close'); |
| alerts.open = true; |
| alerts.count = 0; |
| } |
| |
| function closeAlerts() { |
| $alerts.style('display', 'none') |
| .html(''); |
| alerts.open = false; |
| } |
| |
| function addAlert(msg) { |
| var lines, |
| oldContent; |
| |
| if (alerts.count) { |
| oldContent = $alerts.select('pre').html(); |
| } |
| |
| lines = msg.split('\n'); |
| lines[0] += ' '; // spacing so we don't crowd 'X' |
| lines = lines.join('\n'); |
| |
| if (oldContent) { |
| lines += '\n----\n' + oldContent; |
| } |
| |
| $alerts.select('pre').html(lines); |
| alerts.count++; |
| } |
| |
| function doAlert(msg) { |
| if (!alerts.open) { |
| createAlerts(); |
| } |
| addAlert(msg); |
| } |
| |
| function resize(e) { |
| d3.selectAll('.onosView').call(setViewDimensions); |
| // allow current view to react to resize event... |
| if (current.view) { |
| current.view.resize(current.ctx, current.flags); |
| } |
| } |
| |
| // .......................................................... |
| // View class |
| // Captures state information about a view. |
| |
| // Constructor |
| // vid : view id |
| // nid : id of associated nav-item (optional) |
| // cb : callbacks (preload, reset, load, unload, resize, error) |
| function View(vid) { |
| var av = 'addView(): ', |
| args = Array.prototype.slice.call(arguments), |
| nid, |
| cb; |
| |
| args.shift(); // first arg is always vid |
| if (typeof args[0] === 'string') { // nid specified |
| nid = args.shift(); |
| } |
| cb = args.shift(); |
| |
| this.vid = vid; |
| |
| if (validateViewArgs(vid)) { |
| this.nid = nid; // explicit navitem id (can be null) |
| this.cb = $.isPlainObject(cb) ? cb : {}; // callbacks |
| this.$div = null; // view not yet added to DOM |
| this.radioButtons = null; // no radio buttons yet |
| this.ok = true; // valid view |
| } |
| } |
| |
| function validateViewArgs(vid) { |
| var av = "ui.addView(...): ", |
| ok = false; |
| if (typeof vid !== 'string' || !vid) { |
| doError(av + 'vid required'); |
| } else if (views[vid]) { |
| doError(av + 'View ID "' + vid + '" already exists'); |
| } else { |
| ok = true; |
| } |
| return ok; |
| } |
| |
| var viewInstanceMethods = { |
| token: function () { |
| return { |
| // attributes |
| vid: this.vid, |
| nid: this.nid, |
| $div: this.$div, |
| |
| // functions |
| width: this.width, |
| height: this.height, |
| uid: this.uid, |
| setRadio: this.setRadio, |
| setKeys: this.setKeys, |
| dataLoadError: this.dataLoadError, |
| alert: this.alert |
| } |
| }, |
| |
| // == Life-cycle functions |
| // TODO: factor common code out of life-cycle |
| preload: function (ctx, flags) { |
| var c = ctx || '', |
| fn = isF(this.cb.preload); |
| traceFn('View.preload', this.vid + ', ' + c); |
| if (fn) { |
| trace('PRELOAD cb for ' + this.vid); |
| fn(this.token(), c, flags); |
| } |
| }, |
| |
| reset: function (ctx, flags) { |
| var c = ctx || '', |
| fn = isF(this.cb.reset); |
| traceFn('View.reset', this.vid); |
| if (fn) { |
| trace('RESET cb for ' + this.vid); |
| fn(this.token(), c, flags); |
| } else if (this.cb.reset === true) { |
| // boolean true signifies "clear view" |
| trace(' [true] cleaing view...'); |
| viewApi.empty(); |
| } |
| }, |
| |
| load: function (ctx, flags) { |
| var c = ctx || '', |
| fn = isF(this.cb.load); |
| traceFn('View.load', this.vid + ', ' + c); |
| this.$div.classed('currentView', true); |
| if (fn) { |
| trace('LOAD cb for ' + this.vid); |
| fn(this.token(), c, flags); |
| } |
| }, |
| |
| unload: function (ctx, flags) { |
| var c = ctx | '', |
| fn = isF(this.cb.unload); |
| traceFn('View.unload', this.vid); |
| this.$div.classed('currentView', false); |
| if (fn) { |
| trace('UNLOAD cb for ' + this.vid); |
| fn(this.token(), c, flags); |
| } |
| }, |
| |
| resize: function (ctx, flags) { |
| var c = ctx || '', |
| fn = isF(this.cb.resize), |
| w = this.width(), |
| h = this.height(); |
| traceFn('View.resize', this.vid + '/' + c + |
| ' [' + w + 'x' + h + ']'); |
| if (fn) { |
| trace('RESIZE cb for ' + this.vid); |
| fn(this.token(), c, flags); |
| } |
| }, |
| |
| error: function (ctx, flags) { |
| var c = ctx || '', |
| fn = isF(this.cb.error); |
| traceFn('View.error', this.vid + ', ' + c); |
| if (fn) { |
| trace('ERROR cb for ' + this.vid); |
| fn(this.token(), c, flags); |
| } |
| }, |
| |
| // == Token API functions |
| width: function () { |
| return $(this.$div.node()).width(); |
| }, |
| |
| height: function () { |
| return $(this.$div.node()).height(); |
| }, |
| |
| setRadio: function (btnSet) { |
| return setRadioButtons(this.vid, btnSet); |
| }, |
| |
| setKeys: function (keyArg) { |
| setKeyBindings(keyArg); |
| }, |
| |
| uid: function (id) { |
| return makeUid(this, id); |
| }, |
| |
| // TODO : add exportApi and importApi methods |
| // TODO : implement custom dialogs |
| |
| // Consider enhancing alert mechanism to handle multiples |
| // as individually closable. |
| alert: function (msg) { |
| doAlert(msg); |
| }, |
| |
| dataLoadError: function (err, url) { |
| var msg = 'Data Load Error\n\n' + |
| err.status + ' -- ' + err.statusText + '\n\n' + |
| 'relative-url: "' + url + '"\n\n' + |
| 'complete-url: "' + err.responseURL + '"'; |
| this.alert(msg); |
| } |
| |
| // TODO: consider schedule, clearTimer, etc. |
| }; |
| |
| // attach instance methods to the view prototype |
| $.extend(View.prototype, viewInstanceMethods); |
| |
| // .......................................................... |
| // UI API |
| |
| var fpConfig = { |
| TR: { |
| side: 'right' |
| |
| }, |
| TL: { |
| side: 'left' |
| } |
| }; |
| |
| uiApi = { |
| addLib: function (libName, api) { |
| // TODO: validation of args |
| libApi[libName] = api; |
| }, |
| |
| // TODO: implement floating panel as a class |
| // TODO: parameterize position (currently hard-coded to TopRight) |
| /* |
| * Creates div in floating panels block, with the given id. |
| * Returns panel token used to interact with the panel |
| */ |
| addFloatingPanel: function (id, position) { |
| var pos = position || 'TR', |
| cfg = fpConfig[pos], |
| el, |
| fp; |
| |
| if (fpanels[id]) { |
| buildError('Float panel with id "' + id + '" already exists.'); |
| return null; |
| } |
| |
| el = $floatPanels.append('div') |
| .attr('id', id) |
| .attr('class', 'fpanel') |
| .style('opacity', 0); |
| |
| // has to be called after el is set. |
| el.style(cfg.side, pxHide()); |
| |
| function pxShow() { |
| return '20px'; |
| } |
| function pxHide() { |
| return (-20 - widthVal()) + 'px'; |
| } |
| function widthVal() { |
| return el.style('width').replace(/px$/, ''); |
| } |
| |
| fp = { |
| id: id, |
| el: el, |
| pos: pos, |
| |
| show: function () { |
| console.log('show pane: ' + id); |
| el.transition().duration(750) |
| .style(cfg.side, pxShow()) |
| .style('opacity', 1); |
| }, |
| hide: function () { |
| console.log('hide pane: ' + id); |
| el.transition().duration(750) |
| .style(cfg.side, pxHide()) |
| .style('opacity', 0); |
| }, |
| empty: function () { |
| return el.html(''); |
| }, |
| append: function (what) { |
| return el.append(what); |
| }, |
| width: function (w) { |
| if (w === undefined) { |
| return widthVal(); |
| } |
| el.style('width', w); |
| } |
| }; |
| fpanels[id] = fp; |
| return fp; |
| }, |
| |
| // TODO: it remains to be seen whether we keep this style of docs |
| /** @api ui addView( vid, nid, cb ) |
| * Adds a view to the UI. |
| * <p> |
| * Views are loaded/unloaded into the view content pane at |
| * appropriate times, by the navigation framework. This method |
| * adds a view to the UI and returns a token object representing |
| * the view. A view's token is always passed as the first |
| * argument to each of the view's life-cycle callback functions. |
| * <p> |
| * Note that if the view is directly referenced by a nav-item, |
| * or in a group of views with one of those views referenced by |
| * a nav-item, then the <i>nid</i> argument can be omitted as |
| * the framework can infer it. |
| * <p> |
| * <i>cb</i> is a plain object containing callback functions: |
| * "preload", "reset", "load", "unload", "resize", "error". |
| * <pre> |
| * function myLoad(view, ctx) { ... } |
| * ... |
| * // short form... |
| * onos.ui.addView('viewId', { |
| * load: myLoad |
| * }); |
| * </pre> |
| * |
| * @param vid (string) [*] view ID (a unique DOM element id) |
| * @param nid (string) nav-item ID (a unique DOM element id) |
| * @param cb (object) [*] callbacks object |
| * @return the view token |
| */ |
| addView: function (vid, nid, cb) { |
| traceFn('addView', vid); |
| var view = new View(vid, nid, cb), |
| token; |
| if (view.ok) { |
| views[vid] = view; |
| token = view.token(); |
| } else { |
| token = { vid: view.vid, bad: true }; |
| } |
| return token; |
| } |
| }; |
| |
| // .......................................................... |
| // View API |
| |
| // TODO: deprecated |
| viewApi = { |
| /** @api view empty( ) |
| * Empties the current view. |
| * <p> |
| * More specifically, removes all DOM elements from the |
| * current view's display div. |
| */ |
| empty: function () { |
| if (!current.view) { |
| return; |
| } |
| current.view.$div.html(''); |
| } |
| }; |
| |
| // .......................................................... |
| // Nav API |
| navApi = { |
| |
| }; |
| |
| // .......................................................... |
| // Library API |
| libApi = { |
| |
| }; |
| |
| // .......................................................... |
| // Exported API |
| |
| // function to be called from index.html to build the ONOS UI |
| function buildOnosUi() { |
| tsB = new Date().getTime(); |
| tsI = tsB - tsI; // initialization duration |
| |
| console.log('ONOS UI initialized in ' + tsI + 'ms'); |
| |
| if (built) { |
| throwError("ONOS UI already built!"); |
| } |
| built = true; |
| |
| $mastRadio = d3.select('#mastRadio'); |
| |
| $(window).on('hashchange', hash); |
| $(window).on('resize', resize); |
| |
| d3.select('body').on('keydown', keyIn); |
| setupGlobalKeys(); |
| |
| // Invoke hashchange callback to navigate to content |
| // indicated by the window location hash. |
| hash(); |
| |
| // If there were any build errors, report them |
| reportBuildErrors(); |
| } |
| |
| // export the api and build-UI function |
| return { |
| ui: uiApi, |
| lib: libApi, |
| //view: viewApi, |
| nav: navApi, |
| buildUi: buildOnosUi, |
| exported: exported |
| }; |
| }; |
| |
| }(jQuery)); |